<?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: Veristria</title>
    <description>The latest articles on DEV Community by Veristria (@veristria).</description>
    <link>https://dev.to/veristria</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%2F4088170%2Fe4ca5920-1f77-4f11-8ba5-b1b8b9364928.png</url>
      <title>DEV Community: Veristria</title>
      <link>https://dev.to/veristria</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/veristria"/>
    <language>en</language>
    <item>
      <title>The complete anatomy of a Stripe Connect refund</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Mon, 28 Sep 2026 22:34:48 +0000</pubDate>
      <link>https://dev.to/veristria/the-complete-anatomy-of-a-stripe-connect-refund-1gfe</link>
      <guid>https://dev.to/veristria/the-complete-anatomy-of-a-stripe-connect-refund-1gfe</guid>
      <description>&lt;h1&gt;
  
  
  The complete anatomy of a Stripe Connect refund
&lt;/h1&gt;

&lt;p&gt;A single refund can move five different API objects and debit either the platform balance or a connected account's balance, depending on the Connect charge type behind it. This piece walks one $100.00 refund through direct charges, destination charges, and separate charges and transfers, so platform engineers and finance ops can predict every hop before issuing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The objects inside one refund
&lt;/h2&gt;

&lt;p&gt;A refund is one API call, but it is never one object. Issuing one writes a new Refund, updates the Charge, updates the wrapping PaymentIntent, and — depending on parameters and charge type — reaches into Transfer and ApplicationFee children as well. Fix the cast before tracing balances:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Charge&lt;/strong&gt; — the record of the original payment, carrying &lt;code&gt;amount&lt;/code&gt;, &lt;code&gt;amount_refunded&lt;/code&gt;, and the boolean &lt;code&gt;refunded&lt;/code&gt; once nothing is left to return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refund&lt;/strong&gt; — the payback record: &lt;code&gt;amount&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and the &lt;code&gt;balance_transaction&lt;/code&gt; describing the debit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PaymentIntent&lt;/strong&gt; — the lifecycle wrapper around the charge attempt; it tracks cumulative refunds and is the handle most integrations pass to the Refunds API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfer&lt;/strong&gt; — funds moved from the platform to a connected account. Exists only in the destination and separate patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TransferReversal&lt;/strong&gt; — a child of a Transfer that pulls some or all of those funds back to the platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ApplicationFee&lt;/strong&gt; — the platform's fee on a direct or destination charge, held as its own object with &lt;code&gt;amount&lt;/code&gt; and &lt;code&gt;amount_refunded&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ApplicationFeeRefund&lt;/strong&gt; — a child of the ApplicationFee, created when the fee is returned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where each object sits depends on the &lt;a href="https://docs.stripe.com/connect/charges" rel="noopener noreferrer"&gt;charge type&lt;/a&gt;. Direct charges are created on the connected account through the &lt;code&gt;Stripe-Account&lt;/code&gt; header, so the Charge, Refund, and PaymentIntent live there. Destination charges are created on the platform with &lt;code&gt;transfer_data[destination]&lt;/code&gt;, and separate charges pair a platform Charge with decoupled Transfers, so those objects sit on the platform. The table below maps each object to its owning account side and states whether a default refund call touches it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Object&lt;/th&gt;
&lt;th&gt;Lives on&lt;/th&gt;
&lt;th&gt;Touched by a default refund?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Charge&lt;/td&gt;
&lt;td&gt;Connected account (direct); platform (destination, separate)&lt;/td&gt;
&lt;td&gt;Yes, marked refunded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refund&lt;/td&gt;
&lt;td&gt;Same account as the Charge&lt;/td&gt;
&lt;td&gt;Created by the call itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PaymentIntent&lt;/td&gt;
&lt;td&gt;Same account as the Charge&lt;/td&gt;
&lt;td&gt;Updated with refund totals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transfer&lt;/td&gt;
&lt;td&gt;Platform (destination, separate only)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TransferReversal&lt;/td&gt;
&lt;td&gt;Platform, child of the Transfer&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ApplicationFee&lt;/td&gt;
&lt;td&gt;Platform-side record tied to the connected account's charge&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ApplicationFeeRefund&lt;/td&gt;
&lt;td&gt;Platform, child of the ApplicationFee&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;Two absences in that table matter as much as the rows. A direct charge has no Transfer, so there is nothing to reverse. Separate charges and transfers create no ApplicationFee objects, because the platform collects its cut by transferring less. A default refund therefore moves exactly three objects — Charge, Refund, PaymentIntent — and everything else waits for explicit flags or follow-up calls (&lt;a href="https://docs.stripe.com/api/refunds/create" rel="noopener noreferrer"&gt;Refunds API&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The objects also reference each other, which is what makes programmatic auditing possible. A Refund points at its &lt;code&gt;charge&lt;/code&gt; and usually its &lt;code&gt;payment_intent&lt;/code&gt;. An ApplicationFee carries &lt;code&gt;charge&lt;/code&gt; and &lt;code&gt;account&lt;/code&gt;. A Transfer created by &lt;code&gt;transfer_data&lt;/code&gt; traces back to the originating charge, and every TransferReversal carries a &lt;code&gt;source_refund&lt;/code&gt; when a refund caused it. Follow those pointers and a single refund ID expands into the entire money trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who funds the buyer
&lt;/h2&gt;

&lt;p&gt;The buyer is always made whole against the original payment method. The open question is whose Stripe balance funds the payback and what happens when that balance runs short. The table below summarizes debit behavior per charge type; the paragraphs after it expand on the edge cases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Charge type&lt;/th&gt;
&lt;th&gt;Balance debited on refund&lt;/th&gt;
&lt;th&gt;If that balance is short&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct&lt;/td&gt;
&lt;td&gt;Connected account's available balance&lt;/td&gt;
&lt;td&gt;Refund enters status &lt;code&gt;pending&lt;/code&gt; and processes automatically once funded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destination&lt;/td&gt;
&lt;td&gt;Platform balance&lt;/td&gt;
&lt;td&gt;Pending or failed; with a reversal attached and a depleted connected account, the API returns an error instead of creating a pending refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Separate&lt;/td&gt;
&lt;td&gt;Platform balance&lt;/td&gt;
&lt;td&gt;Pending or failed; transfers are never touched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three facts drive the table. First, refunds draw only on the &lt;em&gt;available&lt;/em&gt; balance — money still sitting in &lt;code&gt;pending&lt;/code&gt; cannot fund them, which is why payout timing (charges land in pending and become available on a rolling schedule) shapes what a refund can do (&lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;refunds&lt;/a&gt;, &lt;a href="https://docs.stripe.com/payouts" rel="noopener noreferrer"&gt;payouts&lt;/a&gt;). Second, Stripe states plainly that "Stripe's processing fees from the original transaction aren't returned," which is why every ledger below retains the processing cost somewhere. Third, the debit target differs by construction: Stripe debits the connected account directly for direct charges, and debits the platform balance for destination and separate charges (&lt;a href="https://docs.stripe.com/connect/charges" rel="noopener noreferrer"&gt;connect charges compared&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The direct-charge shortfall path is patient. If the connected account's available balance cannot cover the refund, Stripe creates the refund anyway with status &lt;code&gt;pending&lt;/code&gt; and processes it automatically once payouts or new payments fund the account.&lt;/p&gt;

&lt;p&gt;The destination path is strict when a reversal rides along. A plain destination refund simply debits the platform. But if the request includes &lt;code&gt;reverse_transfer=true&lt;/code&gt; and the connected account lacks the funds to give back, Stripe returns an error rather than creating a pending refund. That asymmetry is useful: you learn at refund time that recovery is impossible, instead of discovering a pending refund that will quietly strand the money with the seller.&lt;/p&gt;

&lt;p&gt;Separate charges are the indifferent case: refunding the charge has no effect on any transfer, ever. Recovery is a separate, manual act covered below.&lt;/p&gt;

&lt;p&gt;One configuration changes none of this debit logic. With &lt;code&gt;on_behalf_of&lt;/code&gt;, settlement moves to the connected account's country and currency, their statement descriptor applies, and country-specific fees kick in — yet for destination and separate charges the platform balance is still debited for refunds and disputes (&lt;a href="https://docs.stripe.com/connect/charges" rel="noopener noreferrer"&gt;connect charges compared&lt;/a&gt;). Whose descriptor the buyer sees has no bearing on whose balance absorbs the refund.&lt;/p&gt;

&lt;p&gt;Failure handling closes the loop. A failed refund returns the funds to your balance, within up to about 30 days, with &lt;code&gt;failure_balance_transaction&lt;/code&gt; and &lt;code&gt;failure_reason&lt;/code&gt; populated and a &lt;code&gt;refund.failed&lt;/code&gt; event fired. Track that balance transaction back into your books so the returned funds are not counted twice. For destination charges, failed or canceled refunds deposit back into the platform balance, since the platform funded them in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct charges, walked through the ledger
&lt;/h2&gt;

&lt;p&gt;Assumptions for every ledger in this piece:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US platform, USD throughout.&lt;/li&gt;
&lt;li&gt;Stripe's standard US card pricing assumed: 2.9% + $0.30, per &lt;a href="https://stripe.com/pricing" rel="noopener noreferrer"&gt;Stripe's published pricing&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Charge of $100.00 (10000 cents) carrying &lt;code&gt;application_fee_amount&lt;/code&gt; of $10.00 (1000 cents).&lt;/li&gt;
&lt;li&gt;Stripe's processing fee billed to the connected account, the standard direct-charge arrangement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The processing fee follows from the assumption: 2.9% of $100.00 is $2.90, plus the $0.30 fixed component, giving $3.20 (320 cents).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sale.&lt;/strong&gt; The connected account receives the payment net of the processing fee and your fee:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gross charge $100.00
processing fee -$3.20
application fee to you -$10.00
seller net $86.80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your platform holds $10.00 of fee income. Conservation check: $86.80 + $10.00 + $3.20 = $100.00.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full refund, defaults.&lt;/strong&gt; You call the Refunds API with no flags. Stripe debits the connected account's available balance by the full amount and the application fee stays with you. Stripe's documentation is explicit: "Application fees aren't automatically refunded when issuing a refund. Your platform must explicitly refund the application fee or the connected account—the account on which the charge was created—loses that amount" (&lt;a href="https://docs.stripe.com/connect/direct-charges" rel="noopener noreferrer"&gt;direct charges&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;seller before $86.80
refund debit -$100.00
seller after -$13.20
your fee kept +$10.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seller is now negative by exactly the two costs Stripe does not return: the $10.00 fee you kept and the $3.20 processing fee. Conservation: -$13.20 + $10.00 + $3.20 = $0, with the buyer whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full refund with &lt;code&gt;refund_application_fee=true&lt;/code&gt;.&lt;/strong&gt; The flag pushes the fee back to the connected account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;seller -$13.20
fee refund +$10.00
seller final -$3.20
your position $10.00 - $10.00 = $0.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seller now absorbs exactly the non-returned processing fee and you break even. That is the honest floor for a direct-charge platform on a full refund: someone pays Stripe $3.20, and by default it is your seller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial refund of $40.00 with the flag.&lt;/strong&gt; Proportionality applies to the fee as well. The fee refund is 40% of $10.00:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fee share 0.40 x $10.00 = $4.00
seller -$40.00 + $4.00 = -$36.00
your position $10.00 - $4.00 = $6.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seller again eats the unreturned slice of the processing fee, scaled to the refund. Every direct-charge refund, full or partial, is a choice between these two shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Destination charges, walked through the ledger
&lt;/h2&gt;

&lt;p&gt;Keep the same assumptions and add &lt;code&gt;transfer_data[destination]&lt;/code&gt; pointing at the seller, with &lt;code&gt;application_fee_amount&lt;/code&gt; of 1000 cents (&lt;a href="https://feeguard.dev/answers/what-is-a-destination-charge" rel="noopener noreferrer"&gt;what a destination charge is&lt;/a&gt;). The transfer to the seller defaults to the charge amount minus the application fee: $100.00 - $10.00 = $90.00.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sale.&lt;/strong&gt; Two platform-side balance transactions appear immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;charge net of processing +$96.80 (10000c - 320c)
transfer to seller -$90.00
platform margin $6.80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seller's connected balance gains $90.00 and Stripe retains $3.20. Conservation: $6.80 + $90.00 + $3.20 = $100.00.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full refund, defaults.&lt;/strong&gt; Both flags false. Per Stripe's destination-charge refund documentation, the destination account keeps the transferred funds and the platform covers the refund (&lt;a href="https://docs.stripe.com/connect/destination-charges" rel="noopener noreferrer"&gt;destination charges&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;margin $6.80
refund -$100.00
platform position -$93.20
seller +$90.00 (unchanged)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conservation: -$93.20 + $90.00 + $3.20 = $0. This is the default leak: the buyer is made whole, the seller keeps everything, and the platform funds the difference. Partials scale the same mechanics down — with the flag set, Stripe reverses a proportional slice of the transfer rather than the whole thing, computed against the refunded fraction of the charge. Platforms that discover this pattern months late are the reason &lt;a href=""&gt;destination-charge refund accounting&lt;/a&gt; deserves its own runbook; FeeGuard's detectors look for exactly this signature — platform-funded refunds with transfers left standing (&lt;a href=""&gt;more on the leak&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full refund with both flags true.&lt;/strong&gt; Now &lt;code&gt;reverse_transfer=true&lt;/code&gt; and &lt;code&gt;refund_application_fee=true&lt;/code&gt; ride together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;margin (from sale) +$6.80
refund -$100.00
transfer reversal +$90.00
platform subtotal -$3.20
application fee refund -$10.00
platform final -$13.20
seller: $90.00 - $90.00 + $10.00 = +$10.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conservation: -$13.20 + $10.00 + $3.20 = $0, buyer at zero. Read the seller line twice. The reversal claws back the $90.00, then the fee refund hands $10.00 straight back. Application fee refunds compensate the connected account, never the buyer, so on destination charges the both-flags reflex over-compensates your seller by the full fee on every full refund. Set the flags deliberately, per refund policy, not by habit.&lt;/p&gt;

&lt;p&gt;A useful policy test before standardizing: ask who should be $10.00 richer after a fully refunded $100.00 sale. If the answer is nobody, &lt;code&gt;refund_application_fee&lt;/code&gt; belongs only in cases where you intend to compensate the seller explicitly, not in your default refund path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate charges and transfers, walked through the ledger
&lt;/h2&gt;

&lt;p&gt;New inputs: the $100.00 charge sits on your platform and you separately transfer $70.00 to the seller. Your margin is $96.80 - $70.00 = $26.80. Stripe's documentation is blunt about refunds: "refunding a charge has no impact on any associated transfers. It's up to your platform to reconcile any amount owed back to it by reducing subsequent transfer amounts or by reversing transfers" (&lt;a href="https://docs.stripe.com/connect/separate-charges-and-transfers" rel="noopener noreferrer"&gt;separate charges and transfers&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full refund, defaults.&lt;/strong&gt; Nothing automatic reaches the transfer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;charge net +$96.80
transfer -$70.00
refund -$100.00
platform position -$73.20
seller +$70.00 (untouched)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conservation: -$73.20 + $70.00 + $3.20 = $0. You are out the buyer's $100.00 minus the processing fee you already retained, and the seller is up $70.00 until you act. The corrective move is a transfer reversal, and it carries a hard precondition: the connected account's available balance must cover the reversal amount or the call fails. If it succeeds, +$70.00 returns and the platform rests at -$73.20 + $70.00 = -$3.20 — the permanent processing-fee residue. Reducing subsequent transfer amounts instead of reversing works too, and sidesteps that balance gate, at the price of running the reconciliation yourself. Note also that when an async payment method fails on this pattern, Stripe does not reverse anything either; every recovery here is manual.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two flags that decide where the money goes
&lt;/h2&gt;

&lt;p&gt;Everything above reduces to two booleans on the &lt;a href="https://docs.stripe.com/api/refunds/create" rel="noopener noreferrer"&gt;Refunds create call&lt;/a&gt;. The table lays out their behavior side by side.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Proportional?&lt;/th&gt;
&lt;th&gt;Who can set it&lt;/th&gt;
&lt;th&gt;Destination default&lt;/th&gt;
&lt;th&gt;Direct default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund_application_fee&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns the application fee, landing back with the connected account&lt;/td&gt;
&lt;td&gt;Full refund returns the full fee; partial returns a proportional share&lt;/td&gt;
&lt;td&gt;Only the application that created the charge&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reverse_transfer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Creates a TransferReversal against the original Transfer&lt;/td&gt;
&lt;td&gt;"The transfer will be reversed proportionally to the amount being refunded"&lt;/td&gt;
&lt;td&gt;Only the application that created the charge&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two quotations anchor the restrictions, both from the parameter reference. On fees: "An application fee can be refunded only by the application that created the charge." On transfers: "The transfer will be reversed proportionally to the amount being refunded."&lt;/p&gt;

&lt;p&gt;Defaults deserve emphasis: both flags are false everywhere, for every charge type. Silence is the expensive option. Three operational notes follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On destination charges, refunding the fee requires reversing the transfer in the same request; you may instead leave the flag false and refund the fee separately through the Application Fees Refund API afterwards (&lt;a href=""&gt;flag explainer&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;On direct charges, &lt;code&gt;reverse_transfer&lt;/code&gt; is inert — no transfer exists to act on (&lt;a href=""&gt;flag explainer&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;On separate charges, neither flag finds an object to touch; recovery is always a standalone reversal against the transfer you chose to send.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat both flags as policy encoded per request, not global configuration. Teams that standardize them inside one refund service — with the charge type deciding the defaults — stop relitigating this arithmetic every time support issues a refund by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Events and statuses to watch
&lt;/h2&gt;

&lt;p&gt;A refund is asynchronous machinery wearing a synchronous-looking interface, so wire bookkeeping to events rather than HTTP responses. Statuses traverse a small machine: &lt;code&gt;succeeded&lt;/code&gt;, &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;, &lt;code&gt;requires_action&lt;/code&gt;. Pending means Stripe is waiting — usually on funds or a slow payment method. Failed and canceled mean the payback aborted and the money lands back in your balance. Requires_action means the customer must complete a step before the payment method accepts the refund.&lt;/p&gt;

&lt;p&gt;One nuance saves support tickets: a refund issued shortly after the charge may settle as a card-network &lt;em&gt;reversal&lt;/em&gt; rather than a standard refund. The signal is &lt;code&gt;destination_details[card][type]&lt;/code&gt; reading &lt;code&gt;reversal&lt;/code&gt; instead of &lt;code&gt;refund&lt;/code&gt;. It is cheaper on the network side and produces no acquirer reference number (&lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;refunds&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The table below lists the events relevant to refund-time money movement and what to verify in each handler (&lt;a href="https://docs.stripe.com/refunds#refund-events" rel="noopener noreferrer"&gt;event list&lt;/a&gt;). Webhook delivery is at-least-once, so handlers should tolerate duplicates.&lt;/p&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;Fires when&lt;/th&gt;
&lt;th&gt;What to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund.created&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A refund request is accepted&lt;/td&gt;
&lt;td&gt;Amount, currency, metadata tags for reconciliation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund.updated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The refund's status changes&lt;/td&gt;
&lt;td&gt;Transitions out of &lt;code&gt;pending&lt;/code&gt;; age of stuck refunds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund.failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Processing fails; funds return to your balance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;failure_reason&lt;/code&gt;, &lt;code&gt;failure_balance_transaction&lt;/code&gt;, re-issue decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;charge.refunded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The charge is refunded, partially or fully&lt;/td&gt;
&lt;td&gt;Cumulative &lt;code&gt;amount_refunded&lt;/code&gt; against your order system (&lt;a href=""&gt;event explainer&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;application_fee.refunded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A fee is refunded by flag or by the fee-refund API&lt;/td&gt;
&lt;td&gt;Refunded amount equals the proportional expectation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transfer.reversed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A transfer is reversed in whole or part&lt;/td&gt;
&lt;td&gt;Sum of reversals against the expected proportional amount&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Do not expect &lt;code&gt;transfer.reversed&lt;/code&gt; without cause — outside the flagged refund path and async-failure cleanup on destination charges, reversals happen only when you call for them (&lt;a href=""&gt;can Stripe reverse transfers automatically?&lt;/a&gt;). Stripe has deprecated the older &lt;code&gt;charge.refund.updated&lt;/code&gt; event; migrate anything still listening to &lt;code&gt;refund.updated&lt;/code&gt;. For timing-sensitive reconciliation, subscribe to &lt;code&gt;balance.available&lt;/code&gt; as well: it marks the moment pending funds become usable, which is exactly when a stuck pending refund becomes fundable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Stripe reverse the transfer automatically?
&lt;/h3&gt;

&lt;p&gt;Only in specific cases. On destination charges, passing &lt;code&gt;reverse_transfer=true&lt;/code&gt; makes Stripe create the reversal as part of the refund — the entire transfer for a full refund, a proportional slice for a partial one. When an async payment method fails after a destination charge settles, Stripe reverses the transfer on its own. Separate charges and transfers never see an automatic reversal, and direct charges have no transfer to reverse.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did the connected account go negative after a refund?
&lt;/h3&gt;

&lt;p&gt;Because the charge was direct. Refunds on direct charges debit the connected account's available balance, and the application fee stays with you unless you passed &lt;code&gt;refund_application_fee=true&lt;/code&gt;. A negative balance pauses payouts until future payments offset it, and Stripe attempts a debit of the account's external bank account only when &lt;code&gt;debit_negative_balances&lt;/code&gt; is enabled for supported regions (&lt;a href="https://docs.stripe.com/connect/account-balances" rel="noopener noreferrer"&gt;account balances&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Who receives an application fee refund?
&lt;/h3&gt;

&lt;p&gt;The connected account, always. Refunding a fee pushes the fee funds back to the connected account — it never reroutes money to the buyer. On destination charges this is why refunding the fee without reversing the transfer distorts the ledger: the seller pockets the fee on top of the transfer you failed to claw back.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the platform balance cannot cover the refund?
&lt;/h3&gt;

&lt;p&gt;Refunds draw on the available balance only. Card refunds that cannot be funded sit in &lt;code&gt;pending&lt;/code&gt; and complete automatically once the balance recovers. Some payment methods cannot pend and fail instead; a failed refund returns the funds to your balance within up to about 30 days, leaving &lt;code&gt;failure_reason&lt;/code&gt; and &lt;code&gt;failure_balance_transaction&lt;/code&gt; behind so you can decide whether to re-issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the 90-day audit
&lt;/h2&gt;

&lt;p&gt;Every figure in this piece is reproducible from your own Stripe data: charges, refunds, transfers, reversals, and fee objects joined by ID. FeeGuard runs exactly that join. Point a restricted, read-only API key at your platform and the free audit reads your last 90 days of Connect activity, reporting every unreclaimed application fee, unreversed transfer, and uncovered dispute loss with the amounts attached. You get the complete answer first; ongoing monitoring that catches each new occurrence as it happens is optional afterward.&lt;/p&gt;

&lt;p&gt;&lt;a href=""&gt;Run the free 90-day audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>the</category>
      <category>complete</category>
      <category>anatomy</category>
    </item>
    <item>
      <title>GRANTs versus RLS: two permission systems, one database</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Mon, 28 Sep 2026 09:27:27 +0000</pubDate>
      <link>https://dev.to/veristria/grants-versus-rls-two-permission-systems-one-database-58b1</link>
      <guid>https://dev.to/veristria/grants-versus-rls-two-permission-systems-one-database-58b1</guid>
      <description>&lt;h1&gt;
  
  
  GRANTs versus RLS: two permission systems, one database
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Supabase tables sit behind two permission systems at once: SQL grants and row-level security. They layer rather than replace each other, fail with different symptoms, and are routinely confused. This article maps the interaction with runnable tests for every claim.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ask "who can read this table?" about a Supabase project and you've actually asked two questions wearing one coat. Postgres answers access through &lt;strong&gt;grants&lt;/strong&gt; — table-level privileges like &lt;code&gt;SELECT&lt;/code&gt; granted to roles — and through &lt;strong&gt;row security policies&lt;/strong&gt;, which filter rows per command per role. Both systems must permit an action; either alone can block it; and they fail so differently that knowing which gate refused you is most of debugging.&lt;/p&gt;

&lt;p&gt;Confusion between the layers is endemic because Supabase provisions sensible defaults: client roles arrive pre-granted on public-schema tables, so teams live entirely in policy-land and forget grants exist — until a revoke, a new role, or a fresh environment makes the forgotten layer bite. This article separates the two systems, shows their interaction with verified SQL, and gives you the diagnostic habit that tells you instantly which gate said no.&lt;/p&gt;

&lt;p&gt;Everything below was executed against current Postgres during preparation; expected results are stated before each test, in the site's standard style. By the end you'll be able to read any access mystery — empty results, permission errors, tables that work in one environment and fail in another — as a question about one specific gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gates, checked in order
&lt;/h2&gt;

&lt;p&gt;When a query arrives under a given role, Postgres consults two independent systems sequentially — and their verdicts compose multiplicatively: both must say yes for anything to happen. Neither can substitute for the other, which is why the order of checks below determines not just whether your query runs but what kind of failure you see when it doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Privilege check: does this role hold SELECT on this table?
 No -&amp;gt; ERROR: permission denied (42501 class)
 Yes -&amp;gt; continue
2. Row security: do policies admit each row?
 RLS disabled -&amp;gt; all rows pass
 RLS enabled -&amp;gt; rows failing USING are silently filtered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical asymmetry is in the failure shapes. A missing grant is &lt;strong&gt;loud&lt;/strong&gt; — the query errors, naming the table and privilege. Missing or restrictive policies are &lt;strong&gt;quiet&lt;/strong&gt; — empty results, no error, indistinguishable from absent data. Loud failures get noticed and fixed immediately; quiet ones can persist for months. That's why the interaction matters less than the &lt;em&gt;symptom mapping&lt;/em&gt;: errors mean grants, empties mean policies, and every mystery in between is usually both layers interacting with assumptions nobody wrote down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interaction matrix
&lt;/h2&gt;

&lt;p&gt;All four combinations, each verified during this article's preparation — this matrix is the article's centerpiece, and worth internalizing until you can reconstruct it from memory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Grant&lt;/th&gt;
&lt;th&gt;Policy situation&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Permissive policy admits row&lt;/td&gt;
&lt;td&gt;Row returned&lt;/td&gt;
&lt;td&gt;Normal operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Policies deny (or none exist)&lt;/td&gt;
&lt;td&gt;Empty result&lt;/td&gt;
&lt;td&gt;Silent; looks like missing data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Any policy state&lt;/td&gt;
&lt;td&gt;Permission denied error&lt;/td&gt;
&lt;td&gt;Loud; blocks feature visibly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;RLS also disabled&lt;/td&gt;
&lt;td&gt;Permission denied error&lt;/td&gt;
&lt;td&gt;Grants were protecting you&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read row four twice, because it carries a surprise: a table with RLS &lt;em&gt;disabled&lt;/em&gt; but grants revoked still refuses client reads. Teams discovering this sometimes conclude grants are redundant with RLS — the opposite lesson is correct. Default-deny grants on unexposed schemas are legitimate defense-in-depth; what makes Supabase's default posture work is that exposed-schema tables get client grants provisioned automatically, making RLS the single active filter in normal operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each system is actually for
&lt;/h2&gt;

&lt;p&gt;The two systems aren't redundant because they operate at different granularities for different purposes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Grants&lt;/th&gt;
&lt;th&gt;Row security policies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Granularity&lt;/td&gt;
&lt;td&gt;Whole table (or columns)&lt;/td&gt;
&lt;td&gt;Individual rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expression&lt;/td&gt;
&lt;td&gt;Privilege names (&lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Arbitrary boolean expressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure shape&lt;/td&gt;
&lt;td&gt;Error, every time&lt;/td&gt;
&lt;td&gt;Silent filtering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Natural audience&lt;/td&gt;
&lt;td&gt;Database administrators&lt;/td&gt;
&lt;td&gt;Application developers modeling access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change cadence&lt;/td&gt;
&lt;td&gt;Rarely — structural&lt;/td&gt;
&lt;td&gt;Often — follows product features&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Grants answer "may this role touch this object at all" — a database-administration question, answered rarely, changed deliberately. Policies answer "which rows of this object may this caller see or change right now" — a product question, answered per query, evolved continuously. Supabase's design leans into exactly this division: provision broad grants once, then do all real access modeling through policies that developers can read and migrate like code.&lt;/p&gt;

&lt;p&gt;The trouble starts when teams use one system to do the other's job. Modeling row access through grants is impossible — there's no WHERE clause. But modeling &lt;em&gt;structural&lt;/em&gt; decisions through policies happens constantly: revoking table access via deny-all policies instead of explicit grants, or worse, leaving grants wide while assuming policies cover internal tables nobody wrote rules for yet. Each system left doing the other's work produces confusion that surfaces months later as either mysterious errors (grants) or silent exposure (policies).&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests, runnable as printed
&lt;/h2&gt;

&lt;p&gt;Create the fixture and prove each cell:&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;table&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt; &lt;span class="k"&gt;values&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="s1"&gt;'visible'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'also visible'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"gate_demo_all"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
 &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&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;Cell one — both gates open:&lt;/strong&gt; rows return.&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;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;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;both_open&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected as executed: &lt;code&gt;2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row three — grant removed, loud failure:&lt;/strong&gt;&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;revoke&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;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;gate_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: &lt;code&gt;ERROR: permission denied for table gate_demo&lt;/code&gt; — an exception, not an empty set. Restore with &lt;code&gt;grant select ...&lt;/code&gt; before continuing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row two — grant restored, policy tightened to deny:&lt;/strong&gt;&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;drop&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"gate_demo_all"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- zero policies remain: default deny&lt;/span&gt;

&lt;span class="k"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;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;deny_all&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gate_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: &lt;code&gt;0&lt;/code&gt;, no error. Same table, same grant, opposite symptom class from the previous test — that contrast is the entire diagnostic skill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The anonymous variant&lt;/strong&gt;, since public surfaces deserve their own proof:&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;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&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;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;gate_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: &lt;code&gt;0&lt;/code&gt; — anon holds no grant issues (default provisioning covers it) but no policy targets &lt;code&gt;anon&lt;/code&gt;, so default deny filters everything. Swap any element — revoke the grant, or add an &lt;code&gt;anon&lt;/code&gt; policy with &lt;code&gt;using (true)&lt;/code&gt; — and watch which symptom appears. Running these five variations against your own tables takes minutes and produces a complete behavioral fingerprint of both gates.&lt;/p&gt;

&lt;p&gt;One more interaction surprises people: &lt;strong&gt;writes need sequence rights too&lt;/strong&gt; when tables use serial-style defaults. Supabase's default privileges cover sequences alongside tables, so PostgREST inserts just work — but hand-rolled roles in fresh environments can hit insert failures whose fix (&lt;code&gt;grant usage on sequence&lt;/code&gt;) has nothing to do with either RLS or the obvious table grant. When an INSERT fails with permission denied mentioning a sequence name, this is why — and it is a grant problem, not a policy problem, which is exactly the distinction our &lt;a href="https://rowshield.dev/solutions/grants-to-anon-role-directly" rel="noopener noreferrer"&gt;direct-grants finding&lt;/a&gt; documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Supabase composes the two by default
&lt;/h2&gt;

&lt;p&gt;Understanding defaults explains why the platform feels policy-centric:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New public-schema tables receive client-role grants automatically via default privileges — so grants are effectively always-passing unless you changed them.&lt;/li&gt;
&lt;li&gt;RLS is opt-in per table, so the &lt;em&gt;only&lt;/em&gt; active gate on a fresh unprotected table is... neither: grants pass, RLS off, rows flow.&lt;/li&gt;
&lt;li&gt;Once RLS enables, policies become the sole meaningful filter for client roles, and grants fade into background plumbing — which is also why a table can end up &lt;a href=""&gt;RLS disabled without anyone noticing&lt;/a&gt; until an outside check runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This composition is coherent — it makes protected tables work out of the box and keeps API behavior predictable — but it concentrates attention entirely on policies. The residual risks live at the edges: tables where someone revoked grants (features break loudly), environments where default privileges differ (staging behaving unlike production), and roles beyond the standard trio whose grants nobody reviewed since creation.&lt;/p&gt;

&lt;p&gt;The mechanism behind those defaults is worth knowing by name, because it's also the tool for changing them: &lt;code&gt;ALTER DEFAULT PRIVILEGES&lt;/code&gt;. Supabase uses it to pre-grant client roles on future tables; you can inspect or extend it:&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;pg_get_userbyid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defaclrole&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;granting_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;defaclnamespace&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;defaclacl&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_default_acl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row is a standing instruction — "whenever a table appears in this schema, grant these privileges to these roles." Teams adding custom roles (an analytics reader, an integration account) should add their own default-privilege rows rather than remembering per-table grants, which keeps new tables automatically consistent with intent instead of relying on migration authors copying grant statements forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing which gate refused you
&lt;/h2&gt;

&lt;p&gt;The symptom-to-cause table that saves debugging sessions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Refusing gate&lt;/th&gt;
&lt;th&gt;First check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;permission denied for table X&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Grants&lt;/td&gt;
&lt;td&gt;&lt;code&gt;has_table_privilege(role, table, 'SELECT')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;permission denied for schema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Schema usage grant&lt;/td&gt;
&lt;td&gt;Grant USAGE on the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;permission denied for sequence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sequence grant behind serial column&lt;/td&gt;
&lt;td&gt;Grant usage on the sequence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query succeeds, returns fewer rows than expected&lt;/td&gt;
&lt;td&gt;RLS policies filtering&lt;/td&gt;
&lt;td&gt;Read policies for caller's role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query succeeds, returns everything&lt;/td&gt;
&lt;td&gt;RLS disabled or tautology&lt;/td&gt;
&lt;td&gt;Catalog check: flags and quals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three produce errors pointing at their own names; only the last two require interpretation, and both resolve to reading the same catalog view you already know:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&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;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'authenticated'&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;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SELECT'&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;auth_can_select&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&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;oid&lt;/span&gt; &lt;span class="o"&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;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&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;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&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;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two boolean columns per table — grant and flag together — answer "is this table's silence a policy question or a privilege question" for your whole schema at once. Add the policy count subquery from earlier audits when the answer needs depth.&lt;/p&gt;

&lt;p&gt;A quick story makes the diagnostic stick. A team reports "the dashboard shows no data but there are no errors." Error-free plus empty means policies filtering — or grants fine, since errors would appear otherwise. Their policy dump shows a correct-looking select policy... targeting &lt;code&gt;service_role&lt;/code&gt; by mistake in a copy-paste. The caller is &lt;code&gt;authenticated&lt;/code&gt;; no policy matches that role; default deny filters everything; zero errors. One catalog read, one wrong role list, fixed by editing one word. Before learning this mapping, the same team had spent two days on cache-busting and client refactors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical rules for living with both
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Leave Supabase's default grants alone&lt;/strong&gt; for client-facing tables; express access control through policies, which is where row-level decisions belong and where review tooling looks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use revokes deliberately&lt;/strong&gt; for defense-in-depth on internal tables — but document them, because revokes are invisible in application code and surprising in fresh environments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never treat grants as your access control&lt;/strong&gt; for exposed tables. They're all-or-nothing per table; they bypass nothing about row scoping; and they're one accidental re-grant away from irrelevance. Policies carry the actual model. For the full inventory habit, pair it with &lt;a href=""&gt;listing every RLS policy&lt;/a&gt; after environment changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test both gates&lt;/strong&gt; after environment changes: one query per role per sensitive table catches grant drift that policy reviews never see. Grants fail loud, policies fail quiet - and a five-line test script covering both takes minutes to run against any environment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fifth rule ties the set together: when documenting your authorization model, document both layers explicitly — a one-line note per table stating "grants: default provisioning; policies: the four-command ownership set" prevents future contributors from guessing which system carries which decision. The documentation cost is minutes; the debugging it prevents is measured in evenings.&lt;/p&gt;

&lt;p&gt;Grants and RLS aren't competitors; they're different altitudes of the same air-traffic system. Grants decide which planes may enter the airspace at all; policies decide where each may fly once inside. Confusing them produces either empty skies or collisions — while understanding them produces systems where both layers quietly do their jobs. For the vocabulary of each layer, the &lt;a href=""&gt;RLS glossary&lt;/a&gt; keeps terms straight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  If RLS is enabled, do grants even matter?
&lt;/h3&gt;

&lt;p&gt;Yes, as a precondition: without the grant, queries error before policies evaluate. In normal Supabase operation the default provisioning makes grants a non-issue — which is precisely why anomalies (revokes, custom roles, new environments) surface as confusing errors. Check grants whenever an error mentions permission, and check policies whenever results look wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did my staging environment behave differently from production?
&lt;/h3&gt;

&lt;p&gt;Default privileges are established per-database by the provisioning process. Environments built differently — manual restores, partial dumps, recreated roles — can end up with divergent grants, so identical policies yield different behavior. When staging and production disagree on the same schema, diff both the policies &lt;em&gt;and&lt;/em&gt; the grants before touching code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do column-level grants interact with RLS usefully?
&lt;/h3&gt;

&lt;p&gt;They stack too, and sometimes helpfully: &lt;code&gt;GRANT SELECT (id, title) ON documents TO anon&lt;/code&gt; narrows visible columns at the privilege layer while policies filter rows — two dimensions from two systems in one request. The operational caveat is forgettability: column grants are invisible in application code and easy to contradict when schemas grow. Many teams prefer narrowed views for public column scoping instead, keeping all row-and-column logic in one reviewable place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I revoke the default grants and manage everything explicitly?
&lt;/h3&gt;

&lt;p&gt;That trades convenience for control at real cost: every table needs explicit grants forever, every new role needs its grants enumerated, and mistakes become loud errors rather than quiet drift. The safer default is to leave grants defaulted and put all access modeling into policies — reserving revokes for specific internal tables where belt-and-suspenders genuinely helps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where do function execute privileges fit?
&lt;/h3&gt;

&lt;p&gt;Functions carry their own EXECUTE privilege, granted to PUBLIC by default — which means every function in your schema is callable by every role unless revoked. For definer functions that elevate privileges, that default deserves review: revoke PUBLIC execute and grant explicitly to the roles that should call it. It's the grant-side twin of policy hygiene, and one of the highest-yield items in a manual audit.&lt;/p&gt;




&lt;p&gt;Unsure which gates your tables are actually running? &lt;a href=""&gt;Run the free scan&lt;/a&gt; — paste your app URL and see the outside-visible consequences of both layers, findings included.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vibeguard</category>
      <category>grants</category>
      <category>versus</category>
      <category>rls</category>
    </item>
    <item>
      <title>From weekend prototype to production: hardening a Supabase app</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 25 Sep 2026 09:05:36 +0000</pubDate>
      <link>https://dev.to/veristria/from-weekend-prototype-to-production-hardening-a-supabase-app-gi0</link>
      <guid>https://dev.to/veristria/from-weekend-prototype-to-production-hardening-a-supabase-app-gi0</guid>
      <description>&lt;h1&gt;
  
  
  From weekend prototype to production: hardening a Supabase app
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;The gap between a working prototype and a defensible product is a checklist, not a rewrite. This article gives founders and developers that checklist in order - with every row-level-security step shown as complete SQL - so nothing gets skipped because nobody knew it existed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Prototypes are honest about features and silent about posture. Your app creates accounts, saves data, renders dashboards — and none of that says anything about what happens when someone other than a happy user connects. Production readiness, for the database layer, means being able to answer three questions without hedging: who can read each table, who can write into whose rows, and how you know both answers are still true after the next deploy.&lt;/p&gt;

&lt;p&gt;This article is that answer, sequenced. Each stage builds on the last; every SQL block runs as printed (each was executed during preparation); and the whole sequence fits inside a focused day for a small app. It assumes the prototype already works — auth flows included — because hardening a moving feature set wastes effort; freeze features for the day if you can. The companion piece on &lt;a href="https://rowshield.dev/solutions/prototype-to-production-checklist" rel="noopener noreferrer"&gt;prototype-to-production specifics&lt;/a&gt; covers platform concerns beyond authorization; here we go deep on the layer that protects your users from each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage zero: define what you're protecting
&lt;/h2&gt;

&lt;p&gt;Before running queries, spend ten minutes writing down the access model in plain sentences. Per table: who reads it? Who creates rows, and can they only create their own? Who updates or deletes, and under what limits? Which tables are legitimately public, and with what filters?&lt;/p&gt;

&lt;p&gt;This sounds bureaucratic. It isn't — it's the spec your policies will implement, and having it written turns later stages from judgment calls into conformance checks. A three-table notes app needs three sentences. A marketplace needs a page. Either way, write it before touching the schema, because the most common hardening failure isn't technical at all: it's discovering mid-migration that nobody ever decided whether collaborators should see drafts.&lt;/p&gt;

&lt;p&gt;The sentences also become your review artifacts. When a stakeholder asks "can users see each other's data?", the answer stops being a shrug and becomes a quote from a document — plus, eventually, a pointer at passing tests. Founders who write this page once reuse it in security questionnaires, enterprise sales calls, and their own 2 a.m. incident triage; the words cost minutes and keep paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage one: inventory and triage
&lt;/h2&gt;

&lt;p&gt;Now measure reality against the model you just wrote. The catalog produces the gap analysis:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&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;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policyname&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;policy_count&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&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;oid&lt;/span&gt; &lt;span class="o"&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;relnamespace&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;pg_policies&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schemaname&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="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&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;relname&lt;/span&gt;
&lt;span class="k"&gt;where&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&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;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&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;relname&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;relrowsecurity&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&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;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort the output into three buckets by risk rather than alphabetically:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;Signature&lt;/th&gt;
&lt;th&gt;Action this stage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open windows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rls_enabled = false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fix first — exposure is live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locked rooms&lt;/td&gt;
&lt;td&gt;enabled, &lt;code&gt;policy_count = 0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Write policies in stage two&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claimed territory&lt;/td&gt;
&lt;td&gt;enabled with policies&lt;/td&gt;
&lt;td&gt;Verify against your model in stage five&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prototype projects typically land almost entirely in the second bucket, sometimes with a couple of open windows where an assistant-generated migration skipped protection. Nothing here requires blame; the catalog doesn't do blame, it does state.&lt;/p&gt;

&lt;p&gt;Triage discipline for this stage: don't fix while triaging. The temptation is to enable RLS on the first open table you see — but enabling before its policies exist breaks whatever feature uses it, mid-inventory, and now you're debugging your own hardening. Complete the three-bucket sort first; then work buckets in order with the full picture in hand. Open windows do jump the queue, but they jump into stage two's process rather than skipping it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage two: write the policy set, completely
&lt;/h2&gt;

&lt;p&gt;For each user-owned table, this is the full pattern — four policies covering all four commands, both clauses wherever both apply. The blocks from here on run against the prototype's two working tables; if you are following along in a scratch database rather than your own project, create them first:&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;table&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
 &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;workspace_members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workspace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"documents_select"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
 &lt;span class="k"&gt;using&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"documents_insert"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
 &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"documents_update"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
 &lt;span class="k"&gt;using&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"documents_delete"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
 &lt;span class="k"&gt;using&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details worth understanding rather than copying blindly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both halves of update matter.&lt;/strong&gt; &lt;code&gt;USING&lt;/code&gt; decides which existing rows may change; &lt;code&gt;WITH CHECK&lt;/code&gt; decides whether the resulting row stays legal. With only &lt;code&gt;USING&lt;/code&gt;, users reassign ownership — moving rows out of their scope into someone else's account — in one successful statement. The check clause closes exactly that door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Insert has no USING.&lt;/strong&gt; There's no prior row to test, so the entire contract lives in &lt;code&gt;WITH CHECK&lt;/code&gt;. Requiring the resulting &lt;code&gt;owner_id&lt;/code&gt; to match the caller's verified identity means users cannot plant rows attributed to others, even ones they'll never be able to read afterward.&lt;/p&gt;

&lt;p&gt;A naming convention pays for itself immediately: &lt;code&gt;&amp;lt;table&amp;gt;_&amp;lt;command&amp;gt;&lt;/code&gt; (or with an audience suffix when several policies legitimately coexist) makes every future catalog dump self-describing, and mismatched names become visible drift signals. Add a one-line &lt;code&gt;comment on policy&lt;/code&gt; statement per policy capturing the intent sentence from stage zero — the next reviewer, including you in six months, reads comments before clauses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables shared across a workspace&lt;/strong&gt; extend the select branch with a membership subquery instead of replacing ownership. The composite reads like this:&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;-- Widen the existing policy in place. ALTER, not DROP-then-CREATE:&lt;/span&gt;
&lt;span class="c1"&gt;-- dropping would leave the table with no select policy for a moment.&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"documents_select"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
 &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;
 &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;workspace_members&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
 &lt;span class="k"&gt;where&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;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workspace_id&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;user_id&lt;/span&gt; &lt;span class="o"&gt;=&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;workspace_members&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"workspace_members_select_own"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;workspace_members&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;user_id&lt;/span&gt; &lt;span class="o"&gt;=&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ownership still admits rows directly; membership adds the sharing dimension on top. The membership table needs its own read policy — the second statement above — so users can see their own memberships without seeing everyone's. That dependency matters more than it looks, because subqueries evaluate under the caller's policies too: a members table with RLS enabled and no select policy silently empties every &lt;code&gt;EXISTS&lt;/code&gt; branch built on it, with no error anywhere. Tables that are genuinely public get a deliberate anon-targeted policy with its filters, never an accident of omission; &lt;a href=""&gt;anonymous access done deliberately&lt;/a&gt; covers that design in full.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage three: performance hygiene while you're in there
&lt;/h2&gt;

&lt;p&gt;Policies run on every query forever; make them cheap once:&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;index&lt;/span&gt; &lt;span class="n"&gt;documents_owner_id_idx&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Index every column a policy compares against — owners, tenants, workspace keys. Wrap identity calls as &lt;code&gt;(select auth.uid())&lt;/code&gt; so Postgres evaluates them once per statement instead of per row; the plan-level difference is documented in &lt;a href=""&gt;our performance guide&lt;/a&gt;. If any table carries restrictive gates, confirm they reference indexed columns too. Fifteen minutes here prevents the classic arc where adding RLS quietly degrades every list view until somebody blames the framework.&lt;/p&gt;

&lt;p&gt;The habit generalizes: policy columns deserve the same indexing attention as foreign keys, because they are foreign keys in disguise — predicates matching user identifiers against stored columns on every single query. Where one index serves both a foreign key and its policy, that's one index doing two jobs; where they diverge, add what's missing and let &lt;code&gt;EXPLAIN&lt;/code&gt; arbitrate.&lt;/p&gt;

&lt;p&gt;Also decide now about &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;: enabling it subjects the table owner to policies as well, which matters when backend scripts share the owner role with humans doing dashboard surgery. For tables where even privileged mistakes must respect isolation, add the line — it costs nothing and removes a standing exception you'd otherwise have to remember.&lt;/p&gt;

&lt;p&gt;Verification for this stage is one query per sensitive table, run as a test persona:&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;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request.jwt.claims'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"sub":"11111111-1111-1111-1111-111111111111"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;explain&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;costs&lt;/span&gt; &lt;span class="k"&gt;off&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'aaaaaaaa-0000-0000-0000-00000000000a'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the plan: you want the policy predicate to appear in an &lt;code&gt;Index Cond&lt;/code&gt; (policy served by index), not merely a &lt;code&gt;Filter&lt;/code&gt; after a sequential scan. If the JWT-derived value appears inline in a per-row &lt;code&gt;Filter&lt;/code&gt;, the &lt;code&gt;(select ...)&lt;/code&gt; wrapper is missing; if there's no index candidate at all, stage three isn't done. Two minutes of plan-reading per table now buys years of flat latency later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage four: storage and server paths
&lt;/h2&gt;

&lt;p&gt;Two surfaces outside table RLS routinely undo an otherwise hardened app:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buckets.&lt;/strong&gt; Every bucket gets an explicit visibility decision and, for private buckets, object-level policies scoped by path. The standard own-folder pattern keeps users inside a prefix derived from their identity:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"avatars_read"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"avatars_upload_own_folder"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
 &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
 &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&lt;/span&gt;
 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foldername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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="o"&gt;=&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
 &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uploads into another user's folder now reject at the policy layer — verified behavior, and the same WITH CHECK thinking from table land applied to object paths. Public buckets are legitimate for genuinely public assets; audit them with the same suspicion as public tables, because &lt;a href=""&gt;public-bucket exposure&lt;/a&gt; is served without authentication by definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service credentials.&lt;/strong&gt; Inventory every place the service-role key exists: environment files, function configs, build artifacts. It belongs exclusively behind server boundaries, and its call sites should fit on one screen with justifications attached. If grep finds it anywhere client-reachable, that finding outranks everything else on this page — rotation guidance is in &lt;a href=""&gt;service-role leakage&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Run the inventory against built output, not just source: framework env prefixes, bundler plugins, and copied snippets have all shipped credentials that never appeared in a repository. The check is two greps and takes less time than explaining its absence afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage five: prove it
&lt;/h2&gt;

&lt;p&gt;Claims become claims-with-evidence through the two-account battery: anonymous reads return empty; forged inserts reject; cross-tenant reads return nothing; ownership transfers fail; deletes stay in scope. All five are copy-pasteable in &lt;a href=""&gt;the tenant-isolation playbook&lt;/a&gt;, with expected results stated per probe. Run them against staging first, then production — environments drift independently, and production is where the proof matters.&lt;/p&gt;

&lt;p&gt;This is the stage most easily talked out of, because verification feels redundant when everything was written carefully an hour ago. That feeling is the point of failure: transcription errors, forgotten tables, and misread requirements survive careful work precisely because care does not check itself. Twenty minutes of battery beats hours of post-launch forensics on the one occasion it finds something.&lt;/p&gt;

&lt;p&gt;Score the run as a table, because a table converts anxiety into worklist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Pass looks like&lt;/th&gt;
&lt;th&gt;Fail points at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anonymous read of private table&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing RLS flag or anon policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forged-owner insert&lt;/td&gt;
&lt;td&gt;HTTP 4xx&lt;/td&gt;
&lt;td&gt;Insert policy without check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-tenant read by ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Policy trusting client input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ownership-transfer update&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;td&gt;Update missing WITH CHECK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Out-of-scope delete&lt;/td&gt;
&lt;td&gt;Zero effect&lt;/td&gt;
&lt;td&gt;Delete policy too wide&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every failure names its fix; every pass earns a line in your launch document. Convert every fix made during stages two through four into a denial test in your suite. Hardening that isn't regression-tested decays on the next busy sprint; tests make the posture survive its authors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The launch-week trap: changes that bypass the checklist
&lt;/h2&gt;

&lt;p&gt;Hardened apps regress through unchecklisted channels. Knowing them in advance is most of the defense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hotfix branches&lt;/strong&gt; skip review under time pressure and ship schema changes directly — often with "temporary" policy relaxations nobody reverts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard edits&lt;/strong&gt; change policies from the SQL editor without touching git, so the repo's migrations no longer describe production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restores and environment copies&lt;/strong&gt; replace current state with whatever existed at snapshot time, including pre-hardening protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Quick" data fixes&lt;/strong&gt; by teammates connecting as the owner role quietly establish a habit of bypass-grade access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each channel has the same signature: production state diverges from what stages two through five verified, with nothing in CI to notice. That's why the final stage isn't optional even though it comes after "prove it" — proof has a timestamp, and launch week is precisely when timestamps start expiring fastest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage six: keep it true
&lt;/h2&gt;

&lt;p&gt;Production readiness is a property maintained over time, not achieved once. Three habits carry it forward: the catalog snapshot joins CI so unprotected tables fail builds; policy changes require the same review as code changes; and monitoring watches between deploys for the changes that bypass pipelines entirely — restores, manual edits, hotfix branches.&lt;/p&gt;

&lt;p&gt;Concretely, continuous coverage means knowing about four transition classes without anyone having to remember to look: RLS flags flipping on or off; policies appearing, disappearing, or widening (a new permissive policy is a union expansion); write policies losing their checks; and key material appearing where it doesn't belong. Human review catches these when it happens to be looking; automation catches them when they happen. RowShield exists to automate that class of watching, beginning with a &lt;a href=""&gt;free scan&lt;/a&gt; that establishes your baseline in minutes.&lt;/p&gt;

&lt;p&gt;Ship when all six stages have answers written down. That document — brief, concrete, evidence-linked — is the real deliverable of hardening: not the absence of risk, but the presence of knowledge about which risks you've accepted and which you've closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How long does this take for a small app?
&lt;/h3&gt;

&lt;p&gt;A three-to-eight-table project typically completes all stages in a day: the inventory in minutes, policy writing in a couple of hours, tests and storage another hour or two. Larger schemas scale mostly in stage two, since the per-table pattern repeats.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I ship with some tables still locked-but-policyless?
&lt;/h3&gt;

&lt;p&gt;Yes — enabled with zero policies is safe default-deny, appropriate for backend-only tables awaiting rules. Ship it deliberately and track it; the failure mode is months of interim becoming permanent, which is why the inventory belongs in CI rather than memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to redo this after every feature?
&lt;/h3&gt;

&lt;p&gt;No — you need the &lt;em&gt;inventory&lt;/em&gt; after every feature. New tables enter through the same pipeline (enable, write four policies, index, test), which takes minutes per table when it's habitual. The heavy work was making the pipeline exist; afterwards it's maintenance.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between this and a security audit?
&lt;/h3&gt;

&lt;p&gt;Scope and independence. This checklist covers your authorization layer self-serve; an audit adds external perspective, compliance framing, and breadth across infrastructure. They complement each other — teams that run this sequence arrive at audits with evidence organized and low-severity noise already cleared, which shortens both.&lt;/p&gt;

&lt;h3&gt;
  
  
  We use an ORM that generates policies too. Covered?
&lt;/h3&gt;

&lt;p&gt;Partially. Generated policies deserve identical review — same tautology and missing-check risks, per &lt;a href=""&gt;the vibe-coded posture piece&lt;/a&gt; — but generation plus verification is strictly better than generation alone. The checklist above &lt;em&gt;is&lt;/em&gt; the verification side.&lt;/p&gt;




&lt;p&gt;Establish your baseline in minutes: &lt;a href=""&gt;run the free scan&lt;/a&gt; — paste your app URL and see exactly which stage your project is really at, findings included.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vibeguard</category>
      <category>from</category>
      <category>weekend</category>
      <category>prototype</category>
    </item>
    <item>
      <title>A taxonomy of platform fee leakage</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Thu, 24 Sep 2026 20:30:08 +0000</pubDate>
      <link>https://dev.to/veristria/a-taxonomy-of-platform-fee-leakage-4li2</link>
      <guid>https://dev.to/veristria/a-taxonomy-of-platform-fee-leakage-4li2</guid>
      <description>&lt;h1&gt;
  
  
  A taxonomy of platform fee leakage
&lt;/h1&gt;

&lt;p&gt;This article answers one question for people who own the money on a Stripe Connect platform: exactly which configuration states silently forfeit funds you were entitled to keep or recover? The answer is a finite list of leak vectors, each with a deterministic cost signature and a concrete API check. Walk the checklist against your own account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leakage, defined mechanically
&lt;/h2&gt;

&lt;p&gt;Leakage is money the platform was entitled to keep or entitled to recover that left your balance without an error, a log line, or an alert. Nothing failed. Every API call returned 200. The objects Stripe created are all consistent with what was requested — the problem is what was &lt;em&gt;not&lt;/em&gt; requested.&lt;/p&gt;

&lt;p&gt;Two things that look like leakage are not leakage, and keeping them separate matters when you size the problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fraud losses.&lt;/strong&gt; Radar scores, blocks, and reviews payments for fraud risk before and at payment time (&lt;a href="https://docs.stripe.com/radar" rel="noopener noreferrer"&gt;Radar&lt;/a&gt;). Fraud is adversarial and probabilistic. Leakage is neither: it follows mechanically from flag defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing fees you knowingly pay.&lt;/strong&gt; Stripe's processing fees from the original transaction are not returned when you refund (&lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;Refunds&lt;/a&gt;). That is a published cost of doing business. It becomes leakage only when you lose &lt;em&gt;additional&lt;/em&gt; money around it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything in this article is in the second category's neighborhood: deterministic consequences of refund-time defaults on &lt;a href="https://docs.stripe.com/connect/destination-charges" rel="noopener noreferrer"&gt;destination charges&lt;/a&gt;, &lt;a href="https://docs.stripe.com/connect/direct-charges" rel="noopener noreferrer"&gt;direct charges&lt;/a&gt;, and &lt;a href="https://docs.stripe.com/connect/separate-charges-and-transfers" rel="noopener noreferrer"&gt;separate charges and transfers&lt;/a&gt;. For marketplaces splitting one payment across several parties, the same vectors multiply per payee; we cover that variant in the &lt;a href="https://feeguard.dev/audit/marketplace-refund-leaks" rel="noopener noreferrer"&gt;marketplace refund-leak deep dive&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The master taxonomy
&lt;/h2&gt;

&lt;p&gt;The table below lists every leak vector this site tracks, which charge patterns it affects, what the default does, what one occurrence costs, and how you detect it. Sections that follow expand each detection check into something runnable.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vector&lt;/th&gt;
&lt;th&gt;Charge patterns affected&lt;/th&gt;
&lt;th&gt;Default behavior&lt;/th&gt;
&lt;th&gt;Unit cost signature&lt;/th&gt;
&lt;th&gt;Detection check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L1 Unreversed transfer on refund&lt;/td&gt;
&lt;td&gt;Destination charges; separate charges and transfers&lt;/td&gt;
&lt;td&gt;Destination account keeps transferred funds; separate-pattern refunds have no effect on transfers at all&lt;/td&gt;
&lt;td&gt;Missing reversal up to the full transfer amount (full refund) or the uncovered share (partial)&lt;/td&gt;
&lt;td&gt;Compare Σ reversals against &lt;code&gt;round((amount_refunded / charge.amount) × transfer.amount)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L2 Unrefunded application fee&lt;/td&gt;
&lt;td&gt;Direct charges; destination charges refunded without &lt;code&gt;refund_application_fee&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Application fees are kept by default on both patterns&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;application_fee.amount_refunded&lt;/code&gt; stays 0 while &lt;code&gt;charge.amount_refunded&lt;/code&gt; grows&lt;/td&gt;
&lt;td&gt;Join each ApplicationFee to its charge and compare refunded amounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L3 Uncovered dispute loss&lt;/td&gt;
&lt;td&gt;Destination charges; separate charges (any &lt;code&gt;on_behalf_of&lt;/code&gt; setting)&lt;/td&gt;
&lt;td&gt;Stripe debits the disputed amount plus the dispute fee from the platform balance; the seller keeps the transfer&lt;/td&gt;
&lt;td&gt;Gap = &lt;code&gt;transfer.amount&lt;/code&gt; − Σ reversals after the dispute closes as lost&lt;/td&gt;
&lt;td&gt;List disputes with status &lt;code&gt;lost&lt;/code&gt;, then audit the related transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L4 Non-proportional partial-refund reversal&lt;/td&gt;
&lt;td&gt;Destination charges, partial refunds&lt;/td&gt;
&lt;td&gt;Proportional reversal happens only if you request it; manual flat amounts under-recover&lt;/td&gt;
&lt;td&gt;Expected proportional reversal − actual reversal amount&lt;/td&gt;
&lt;td&gt;Same formula as L1, evaluated per partial refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L5 Cross-border FX spread&lt;/td&gt;
&lt;td&gt;Any pattern where charge currency differs from settlement currency&lt;/td&gt;
&lt;td&gt;Refund converts at the live rate on refund day; the original conversion fee is not returned&lt;/td&gt;
&lt;td&gt;Refund-day debit minus charge-day settlement value&lt;/td&gt;
&lt;td&gt;Recompute expected refund debit from the charge-day rate and diff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L6 Out-of-band refund paths&lt;/td&gt;
&lt;td&gt;All patterns&lt;/td&gt;
&lt;td&gt;Dashboard or manual refunds bypass whatever flag policy lives in your code&lt;/td&gt;
&lt;td&gt;L1/L2 signatures appearing only on refunds lacking your metadata conventions&lt;/td&gt;
&lt;td&gt;Match every Refund object against your application's request logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L7 Integration drift&lt;/td&gt;
&lt;td&gt;Copied or legacy refund endpoints&lt;/td&gt;
&lt;td&gt;A call site copied between charge types omits explicit flags and inherits wrong defaults&lt;/td&gt;
&lt;td&gt;Leaks cluster at specific endpoints rather than randomly&lt;/td&gt;
&lt;td&gt;Inventory every &lt;code&gt;refunds.create&lt;/code&gt; call site; assert both flags are explicit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three properties of this list matter. First, it is closed: these seven vectors exhaust the ways default refund behavior moves platform money to someone else, because refunds touch exactly three objects — the charge, any transfer, and any application fee. Second, each vector has a deterministic unit cost: no probabilities anywhere. Third, all seven are detectable from ordinary API reads with a restricted key; nothing here requires special access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walk the checklist yourself
&lt;/h2&gt;

&lt;p&gt;Each check below runs against live data with the official &lt;a href="https://docs.stripe.com/api/refunds/create" rel="noopener noreferrer"&gt;stripe-node SDK&lt;/a&gt; and a restricted read-only key. The snippets use auto-pagination and stay read-only end to end. FeeGuard automates exactly these joins in its free audit, but the manual method is complete, so run it yourself first.&lt;/p&gt;

&lt;h3&gt;
  
  
  L1: find refunds whose transfer was never (fully) reversed
&lt;/h3&gt;

&lt;p&gt;Group refunds by charge, sum what was refunded, compute the reversal the formula implies, and compare it to the reversals that actually exist:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&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;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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;isString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;string&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;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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;type&lt;/span&gt; &lt;span class="nx"&gt;Sale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;refunded&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;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&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;sales&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;Sale&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;
 &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expand&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;data.charge&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="nf"&gt;autoPagingEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;refund&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;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&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="nf"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&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;prior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sales&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="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;prior&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nx"&gt;prior&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunded&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;refund&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="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nx"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&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="nx"&gt;charge&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="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalMissing&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="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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sales&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;transferId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="k"&gt;as&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;transfer&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&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;reversals&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferReversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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;reversed&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="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;reversal&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reversed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;reversal&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;transfer&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;reversed&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;missing&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
 &lt;span class="nx"&gt;totalMissing&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: expected &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="s2"&gt;, reversed &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, missing &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`total under-reversed across window: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;totalMissing&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;charge.transfer&lt;/code&gt; links a destination charge to its transfer; the separate-charges pattern has no such link, so its reconciliation starts from your own transfer records instead. Amounts are integer cents throughout. If any single transfer has more than 100 reversals, paginate &lt;code&gt;transferReversals.list&lt;/code&gt; fully before summing. For the mechanics of creating reversals after the fact — partial amounts, insufficient-balance errors, and netting — see &lt;a href=""&gt;the unreversed-transfer solution page&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  L2: find sales that were refunded while their application fee was kept
&lt;/h3&gt;

&lt;p&gt;Application fees aren't automatically refunded when issuing a refund — your platform must explicitly refund the fee or the connected account loses that amount (&lt;a href="https://docs.stripe.com/connect/direct-charges#issue-refunds" rel="noopener noreferrer"&gt;direct charges&lt;/a&gt;). The check joins each fee to its charge:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&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;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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;keptFees&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;applicationFees&lt;/span&gt;
 &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expand&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;data.charge&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="nf"&gt;autoPagingEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;fee&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;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&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;refundedOnSale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;refundedOnSale&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
 &lt;span class="nx"&gt;keptFees&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: fee &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fee&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="s2"&gt; kept, sale refunded &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;refundedOnSale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`total kept fees: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;keptFees&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nonzero result means sellers funded refunds out of their own balances while your fee stayed intact — the exact case the direct-charges documentation warns about. We treat this vector in detail in the &lt;a href=""&gt;application-fee leak deep dive&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  L3: find lost disputes where the seller still holds the transfer
&lt;/h3&gt;

&lt;p&gt;On destination and separate patterns alike, Stripe debits the disputed amount &lt;strong&gt;and&lt;/strong&gt; the dispute fee from the platform balance; recovery from the seller is a manual transfer reversal (&lt;a href="https://docs.stripe.com/connect/disputes" rel="noopener noreferrer"&gt;Disputes on Connect&lt;/a&gt;). Check whether that reversal ever happened:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&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;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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;isString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;string&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;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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;let&lt;/span&gt; &lt;span class="nx"&gt;uncovered&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disputes&lt;/span&gt;
 &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expand&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;data.charge&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="nf"&gt;autoPagingEach&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;dispute&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="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="nl"&gt;default&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="p"&gt;}&lt;/span&gt;

 &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&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="nf"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transferId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&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;transfer&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&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;reversals&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferReversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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;reversed&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="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;reversal&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reversed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;reversal&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retained&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;reversed&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;retained&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
 &lt;span class="nx"&gt;uncovered&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;retained&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: seller retains &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;retained&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;transfer&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="s2"&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;h3&gt;
  
  
  L4 through L7 in prose
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;L4&lt;/strong&gt; uses the same rounding rule as L1 applied per partial refund: expected reversal = &lt;code&gt;round((amount_refunded / charge.amount) × transfer.amount)&lt;/code&gt;, missing = expected − Σ existing reversals. The failure mode is a code path that reverses a flat amount, or nothing, on partials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L5&lt;/strong&gt; needs a baseline comparison rather than an object join; the method is short enough that we give it its own article — see the &lt;a href=""&gt;FX slippage deep dive&lt;/a&gt; and the companion piece on refund-day conversion below it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L6&lt;/strong&gt; is organizational: list refunds over the window and match each one to a request your systems made. A refund with none of your metadata conventions and no matching log line came from the Dashboard or a support tool, and whatever flag policy you maintain did not apply to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L7&lt;/strong&gt; is a static check: grep your codebase for every &lt;code&gt;refunds.create&lt;/code&gt; call site and confirm each passes &lt;code&gt;refund_application_fee&lt;/code&gt; and &lt;code&gt;reverse_transfer&lt;/code&gt; explicitly, chosen per charge type. Defaults differ by pattern, so a call site moved between patterns silently changes meaning.&lt;/p&gt;

&lt;p&gt;The whole procedure also exists as a printable sequence in the &lt;a href=""&gt;refund-path audit checklist&lt;/a&gt;, for teams that prefer to work it offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  One charge through the machine
&lt;/h2&gt;

&lt;p&gt;Nothing abstract survives contact with a concrete ledger. Inputs and assumptions, stated plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US platform, USD everywhere, standard US card pricing of 2.9% + $0.30 per Stripe's published pricing.&lt;/li&gt;
&lt;li&gt;One destination charge of $100.00 = 10000¢, with &lt;code&gt;application_fee_amount&lt;/code&gt; = $10.00 (1000¢) and &lt;code&gt;transfer_data.destination&lt;/code&gt; set, so $90.00 moves to the connected account.&lt;/li&gt;
&lt;li&gt;Processing fee: 2.9% × 10000¢ + 30¢ = 290¢ + 30¢ = 320¢ = &lt;strong&gt;$3.20&lt;/strong&gt;, kept by Stripe.&lt;/li&gt;
&lt;li&gt;Buyer later demands a full refund; support issues it with &lt;strong&gt;default flags&lt;/strong&gt;. Then, on a second, identical order, the cardholder disputes the payment and loses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Step arithmetic, each line on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Charge settles net of processing fee: 10000¢ − 320¢ = 9680¢ → &lt;strong&gt;+$96.80&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Transfer to seller: → &lt;strong&gt;−$90.00&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Platform margin after sale: 96.80 − 90.00 = &lt;strong&gt;+$6.80&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Full refund, defaults (no flags): refund debits the platform balance → &lt;strong&gt;−$100.00&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Cumulative position: 6.80 − 100.00 = &lt;strong&gt;−$93.20&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conservation check — the books must balance to zero across parties: −93.20 (platform) + 90.00 (seller still holds) + 3.20 (Stripe kept) = 0. The buyer is whole, Stripe is whole, the seller is untouched, and the entire cost landed on the platform because nobody passed &lt;code&gt;reverse_transfer=true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now the second order ends in a lost dispute. Stripe debits the disputed amount plus the dispute fee from the platform balance; call the fee &lt;strong&gt;F&lt;/strong&gt;, since its exact value comes from Stripe's published schedule per country and card brand (&lt;a href="https://docs.stripe.com/connect/disputes" rel="noopener noreferrer"&gt;disputes doc&lt;/a&gt;). The cumulative table across both orders, including the recovery step on order one:&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;Movement&lt;/th&gt;
&lt;th&gt;Running platform position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Order 1 settles, net of $3.20 fee&lt;/td&gt;
&lt;td&gt;+$96.80&lt;/td&gt;
&lt;td&gt;+$96.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order 1 transfer to seller&lt;/td&gt;
&lt;td&gt;−$90.00&lt;/td&gt;
&lt;td&gt;+$6.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order 1 full refund, default flags&lt;/td&gt;
&lt;td&gt;−$100.00&lt;/td&gt;
&lt;td&gt;−$93.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order 1 transfer reversed manually afterward&lt;/td&gt;
&lt;td&gt;+$90.00&lt;/td&gt;
&lt;td&gt;−$3.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order 2 margin after sale&lt;/td&gt;
&lt;td&gt;+$6.80&lt;/td&gt;
&lt;td&gt;+$3.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order 2 lost dispute: debited $100.00 + F&lt;/td&gt;
&lt;td&gt;−$100.00 − F&lt;/td&gt;
&lt;td&gt;−$96.40 − F&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Conclusion in dollars and cents: the refund-side leak on order one alone cost &lt;strong&gt;$93.20&lt;/strong&gt; — about 13.7 times the $6.80 margin the sale was supposed to earn (93.20 ÷ 6.80 ≈ 13.7) — and even with full recovery the order nets &lt;strong&gt;−$3.20&lt;/strong&gt;, exactly the processing fee Stripe does not return. The lost dispute on order two contributes another &lt;strong&gt;$100.00 + F&lt;/strong&gt; of loss against its own $6.80 margin, because its $90.00 transfer sits with a seller who has no incentive to volunteer it. Two routine outcomes, one shape of charge, and each one erased many multiples of the profit it was meant to produce before anyone noticed. Had order one been refunded with both flags true, the extra &lt;code&gt;refund_application_fee=true&lt;/code&gt; would have pushed another −$10.00 to the platform and +$10.00 to the connected account — fee refunds compensate the seller, never the buyer (&lt;a href="https://docs.stripe.com/connect/destination-charges#issue-refunds" rel="noopener noreferrer"&gt;destination charges&lt;/a&gt;) — which is why flags deserve a written policy rather than muscle memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prioritizing what to chase
&lt;/h2&gt;

&lt;p&gt;You will not chase everything at once, so rank by two mechanical facts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery probability falls with age.&lt;/strong&gt; Connected-account balances drain on the payout schedule, and once funds are paid out to the seller's bank, a reversal succeeds only while their available balance covers it. Recent findings can often be recovered outright; old ones migrate to the practical lever, which is netting — reducing future transfers until the debt clears rather than demanding an instant bank-top-up. Batching many small findings into one scheduled pass beats chasing them individually; the operational recipe lives in &lt;a href=""&gt;bulk reversal of historical findings&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size versus count is a judgment call, not a formula.&lt;/strong&gt; A single large unreversed transfer justifies a human conversation with the seller; hundreds of small kept fees do not, and should go straight to automated netting. State the tradeoff in your policy before the first awkward email, and send the underlying Stripe evidence with any request so the discussion is about facts.&lt;/p&gt;

&lt;p&gt;Two cautions belong in the same paragraph. First, put idempotency keys on every recovery POST so a retried script cannot double-reverse (&lt;a href="https://docs.stripe.com/api/idempotency" rel="noopener noreferrer"&gt;idempotency&lt;/a&gt;). Second, cross-border destination charges created with &lt;code&gt;on_behalf_of&lt;/code&gt; carry sequencing risk: Stripe advises waiting until a dispute is &lt;strong&gt;lost&lt;/strong&gt; before recovering those transfers, because winning means retransferring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention states
&lt;/h2&gt;

&lt;p&gt;Prevention is a set of configuration states, not a habit. The table maps each state to safe or unsafe and says why.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration state&lt;/th&gt;
&lt;th&gt;Safe?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Every &lt;code&gt;refunds.create&lt;/code&gt; call site passes &lt;code&gt;reverse_transfer&lt;/code&gt; and &lt;code&gt;refund_application_fee&lt;/code&gt; explicitly, per charge-type policy&lt;/td&gt;
&lt;td&gt;Safe&lt;/td&gt;
&lt;td&gt;No default is ever consulted; behavior is invariant to refactors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any call site omits either flag&lt;/td&gt;
&lt;td&gt;Unsafe&lt;/td&gt;
&lt;td&gt;Defaults keep the transfer (destination/separate) and the fee (all patterns)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One wrapper service owns all refund creation; other paths removed&lt;/td&gt;
&lt;td&gt;Safe&lt;/td&gt;
&lt;td&gt;Policy lives in exactly one place; drift shows up as compile errors, not leaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staff can issue Dashboard refunds ad hoc&lt;/td&gt;
&lt;td&gt;Unsafe&lt;/td&gt;
&lt;td&gt;Out-of-band refunds bypass flag policy entirely (vector L6)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;charge.refunded&lt;/code&gt; webhook recomputes expected reversal and fee refund, opening a finding on mismatch&lt;/td&gt;
&lt;td&gt;Safe&lt;/td&gt;
&lt;td&gt;Post-verification catches leaks even when creation happened out-of-band&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconciliation of separate-pattern transfers runs only at month-end&lt;/td&gt;
&lt;td&gt;Weak&lt;/td&gt;
&lt;td&gt;Funds may already be paid out; recovery degrades to netting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-border &lt;code&gt;on_behalf_of&lt;/code&gt; destination refunds wait for dispute loss before reversing&lt;/td&gt;
&lt;td&gt;Safe&lt;/td&gt;
&lt;td&gt;Matches Stripe's advised sequencing for cross-border recovery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The webhook row deserves emphasis because it is the only state that also covers humans clicking buttons in the Dashboard: verification happens after the fact, on the object, regardless of origin. Pair it with the wrapper service and the unsafe states stop being reachable in normal operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Doesn't Stripe reverse transfers automatically when I refund?
&lt;/h3&gt;

&lt;p&gt;No. On destination charges the default leaves the transferred funds with the connected account, and pulling them back requires &lt;code&gt;reverse_transfer=true&lt;/code&gt;. On separate charges and transfers, refunding the charge has no effect on any associated transfers at all. The one automatic reversal Stripe performs is for async payment failures on destination charges — a different event from a refund.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which single change prevents the most leakage?
&lt;/h3&gt;

&lt;p&gt;Passing &lt;code&gt;reverse_transfer=true&lt;/code&gt; on every destination-charge refund where your terms say the seller eats refunds. It converts a −$93.20 outcome into −$3.20 on the canonical $100 order, because the reversal returns the seller's portion while the residual equals the processing fee Stripe does not return. Add &lt;code&gt;refund_application_fee&lt;/code&gt; per your fee policy, deliberately.&lt;/p&gt;

&lt;h3&gt;
  
  
  When I refund an application fee, who gets the money?
&lt;/h3&gt;

&lt;p&gt;The connected account, always. Application-fee refunds push the fee funds back to the connected account that paid them; the buyer is made whole by the refund itself, never by the fee leg. On direct charges the effect is starkest: without an explicit fee refund, the seller absorbs the full refund plus loses your fee on top.&lt;/p&gt;

&lt;h3&gt;
  
  
  How far back is a finding worth chasing?
&lt;/h3&gt;

&lt;p&gt;Mechanically there is no expiration on the arithmetic — the objects remain readable and the missing amounts stay computable. Practically, recovery options degrade with age: available balances drain through payouts, and old findings resolve through netting against future transfers or, eventually, write-offs. Anything inside the most recent payout cycle is cheap to fix; everything older should be batched and scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own last 90 days
&lt;/h2&gt;

&lt;p&gt;Every finding above comes from arithmetic that runs silently each time your platform refunds a buyer or absorbs a dispute. FeeGuard exists to surface that arithmetic: the free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreclaimed application fee, and uncovered dispute loss with the amounts attached and the underlying Stripe objects included, so you can verify each line yourself before acting. You get the answer first; ongoing monitoring is optional afterward.&lt;/p&gt;

&lt;p&gt;&lt;a href=""&gt;Run the free 90-day audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>a</category>
      <category>taxonomy</category>
      <category>of</category>
    </item>
    <item>
      <title>Migrations that silently weaken policies</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Thu, 24 Sep 2026 07:43:43 +0000</pubDate>
      <link>https://dev.to/veristria/migrations-that-silently-weaken-policies-4a7h</link>
      <guid>https://dev.to/veristria/migrations-that-silently-weaken-policies-4a7h</guid>
      <description>&lt;h1&gt;
  
  
  Migrations that silently weaken policies
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Migrations rarely attack authorization directly — they just rearrange the objects policies depend on, and protection erodes as a side effect. This article shows the four recurring patterns in runnable SQL, what each leaves behind, and the before/after checklist that catches them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Policies are database objects with dependencies. They reference columns by name, attach to tables by name, and exist only while their prerequisites exist. That makes them first-class casualties of ordinary refactoring: any migration that touches tables or columns can strengthen features while quietly weakening the rules that protect them. Nothing about this is exotic. Every pattern below was executed against current Postgres during this article's preparation, and every one left a working application behind.&lt;/p&gt;

&lt;p&gt;What distinguishes migration-driven drift from other kinds is its direction of surprise: it almost always &lt;em&gt;helps&lt;/em&gt; in the short term. The recreated table stops erroring. The CASCADE unblocks the deploy. The split finishes the feature. Each pattern buys immediate progress with deferred isolation debt, which is exactly the trade teams accept under pressure unless a check exists to price it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why migrations are where policies go to die
&lt;/h2&gt;

&lt;p&gt;Code review has an asymmetry baked into it: reviewers evaluate diffs for behavior the change is &lt;em&gt;about&lt;/em&gt;. A migration adding a &lt;code&gt;notifications&lt;/code&gt; table gets reviewed for column types, indexes, and backfill logic — nobody re-verifies the authorization posture of the whole schema on every PR, because that verification is tedious and manual.&lt;/p&gt;

&lt;p&gt;Meanwhile, policies are post-data objects: they are created after tables, they depend on columns, and they carry no presence in application code. When a migration's blast radius reaches them, no test fails unless someone wrote a test asserting policy existence — which most suites don't. The result is a systematic blind spot, summarized:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Migration act&lt;/th&gt;
&lt;th&gt;What reviewers check&lt;/th&gt;
&lt;th&gt;What actually changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recreate table&lt;/td&gt;
&lt;td&gt;Columns, indexes&lt;/td&gt;
&lt;td&gt;RLS flag gone; every policy gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drop column&lt;/td&gt;
&lt;td&gt;Callers of the column&lt;/td&gt;
&lt;td&gt;Policies referencing it die with CASCADE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename anything&lt;/td&gt;
&lt;td&gt;Updated references in code&lt;/td&gt;
&lt;td&gt;Policies follow automatically (the safe case)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split/merge tables&lt;/td&gt;
&lt;td&gt;New query paths&lt;/td&gt;
&lt;td&gt;Old policies cover none of the new surface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this article walks each row with real SQL, then gives you the two-query checklist that closes the gap regardless of which pattern sneaks through.&lt;/p&gt;

&lt;p&gt;A note on scope before the patterns: everything here concerns &lt;em&gt;schema&lt;/em&gt; migrations, but the same dependency logic applies to data backfills that drop-and-refill tables, and to environment copies that ship schemas between staging and production by hand. Wherever table definitions travel without their post-data objects, the four patterns below are what waits. The names change — "sync script" instead of "migration" — and the outcome does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: recreate the table, lose everything
&lt;/h2&gt;

&lt;p&gt;The most common form is accidental. A type must change, a constraint must be rebuilt, an ORM suggests drop-and-recreate because Postgres lacks some direct alteration. All three variants below were run and produce the same outcome:&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;table&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"projects_select"&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
 &lt;span class="k"&gt;using&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State today: RLS enabled, one ownership policy. Now the refactor:&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;-- Variant A: explicit drop-and-recreate under the same name&lt;/span&gt;
&lt;span class="k"&gt;drop&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Variant B: clone-based rebuild&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;projects_v2&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;including&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- ...copy data, swap names...&lt;/span&gt;

&lt;span class="c1"&gt;-- Variant C: create-table-as shortcut&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;projects_v3&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As executed, all three descendants share one property catalogued immediately after the run:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&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;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&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;oid&lt;/span&gt; &lt;span class="o"&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;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&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;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&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;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; relname | rls_enabled
-------------+-------------
 projects | f
 projects_v2 | f
 projects_v3 | f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;INCLUDING ALL&lt;/code&gt; copies columns, indexes, defaults, even storage parameters — &lt;strong&gt;and not row security&lt;/strong&gt;. &lt;code&gt;CREATE TABLE AS&lt;/code&gt; doesn't pretend to. And variant A's fresh table starts life exactly like any new table: flag off, zero policies. On a Supabase project the client roles' default grants mean each of these tables is immediately readable through the API the moment data lands. The feature being migrated keeps working — better than before, if the old setup had been half-broken. The full anatomy of that window is at &lt;a href="https://rowshield.dev/solutions/new-table-shipped-without-rls" rel="noopener noreferrer"&gt;new table shipped without RLS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Worth internalizing: the danger scales with how &lt;em&gt;routine&lt;/em&gt; the operation feels. A drop-and-recreate performed during an incident gets scrutiny; the same operation embedded in a Friday-afternoon ORM migration gets merged. Teams adopting schema-management tools should read their generated SQL once with this specific question in mind — "does anything here &lt;code&gt;DROP&lt;/code&gt; or re-&lt;code&gt;CREATE&lt;/code&gt; a table my policies live on?" — because tool output optimizes for schema equivalence, and policy objects are not part of what it considers equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: CASCADE amputates a policy legally
&lt;/h2&gt;

&lt;p&gt;Supabase's own dependency tracking will warn you — loudly, even. The question is whether anyone hears it over deadline pressure. Set up a policy that depends on a column:&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;table&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;confidential&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;documents_confidential_gate&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;restrictive&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;confidential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then try the obvious refactor:&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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;drop&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;confidential&lt;/span&gt; &lt;span class="k"&gt;restrict&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres refuses, naming the victim in advance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: cannot drop column confidential of table documents because
 other objects depend on it
DETAIL: policy documents_confidential_gate on table documents
 depends on column confidential of table documents
HINT: Use DROP ... CASCADE to drop the dependent objects too.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That DETAIL line is the database telling you precisely which protection dies if you proceed. The CASCADE path proceeds anyway:&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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;drop&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;confidential&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- NOTICE: drop cascades to policy documents_confidential_gate&lt;/span&gt;
&lt;span class="c1"&gt;-- on table documents&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migration succeeds. Application works. The restrictive gate — possibly the one thing standing between permissive policies and your compliance posture — is gone, recorded only in a NOTICE that CI logs swallow. We watched this exact beat delete a timeline's only defense in &lt;a href=""&gt;the schema drift guide&lt;/a&gt;'s month four; here is the pre-flight query that would have surfaced the dependency before writing any DDL:&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;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="k"&gt;ilike&lt;/span&gt; &lt;span class="s1"&gt;'%confidential%'&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="k"&gt;ilike&lt;/span&gt; &lt;span class="s1"&gt;'%confidential%'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row returned is a policy scheduled for execution if this migration ships with CASCADE. Run it as part of drafting the migration, not after the NOTICE appears — the difference between "we rewrote the gate onto the new classification column in the same PR" and "we discovered the amputation next quarter" is entirely when this query runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: renames are safer than their reputation
&lt;/h2&gt;

&lt;p&gt;Good news for once, verified both ways. Renaming a column updates policy expressions automatically:&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;qual&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'projects'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- (( SELECT auth.uid() AS uid) = owner_id)&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;rename&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;created_by&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;qual&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'projects'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- (( SELECT auth.uid() AS uid) = created_by)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Renaming the table carries its policies along identically. Postgres tracks these dependencies properly, and rename is a metadata operation — no policy loss occurs. The residual risks are human, not mechanical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The rename can break &lt;em&gt;generated clients&lt;/em&gt; and raw SQL elsewhere, producing pressure to "just revert it" via recreate — landing you in Pattern 1.&lt;/li&gt;
&lt;li&gt;Renames don't fix stale intent: a policy now reading &lt;code&gt;created_by = auth.uid()&lt;/code&gt; still encodes whatever assumption it always had, under a fresher name.&lt;/li&gt;
&lt;li&gt;Renaming a column out from under a tautology-adjacent policy changes nothing about its width; renames preserve meaning, including bad meaning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat renames as the pattern that behaves — and let that trust make you &lt;em&gt;more&lt;/em&gt; suspicious of the patterns that don't. There is a second-order risk worth naming: a rename that updates policies correctly can still break application queries, generated types, or embedded dashboards, and the fastest "fix" under pressure is recreating the old column name — sometimes as a drop-and-recreate of the whole table, landing you in Pattern 1 with nobody watching the authorization surface. When a rename ships, watch the follow-up commits for exactly that rebound.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: splits and merges leave orphans and unions
&lt;/h2&gt;

&lt;p&gt;Feature growth often splits a table ("archive old rows") or merges two ("unify profiles"). Both operations interact badly with policy sets:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Splitting&lt;/strong&gt; strands the original policies on the original table. The new sibling — same shape, same sensitivity — inherits nothing, for the same reasons as Pattern 1's clones. Teams remember to migrate queries; policies aren't queries, and nothing red flags their absence until someone probes the new table as an outsider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merging&lt;/strong&gt; is sneakier because nothing looks missing. Suppose two tables each carry an ownership select policy, and a consolidation folds both datasets into one table while porting &lt;em&gt;both&lt;/em&gt; policies verbatim. Permissive policies OR together — the union semantics from &lt;a href=""&gt;policy accumulation&lt;/a&gt; — so rows now match whichever legacy condition is looser. Each policy was individually reviewed and correct; the merge silently promoted the more permissive of the pair to govern the whole combined dataset.&lt;/p&gt;

&lt;p&gt;Neither failure produces an error, a failed test, or a dashboard signal. Both are visible in ten seconds to anyone who lists policies per table after the migration and compares against before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dump-and-restore special case
&lt;/h2&gt;

&lt;p&gt;Schema moves through time by migrations; it also moves by dumps. The two paths treat policies differently, which is where restore-shaped drift enters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A full schema dump&lt;/strong&gt; (&lt;code&gt;pg_dump&lt;/code&gt; without &lt;code&gt;--data-only&lt;/code&gt;) includes policy definitions alongside tables. Restore it faithfully and protection arrives with the schema — this is the safe default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A data-only restore&lt;/strong&gt; into freshly created tables carries no policies at all, because data-only means no post-data objects. If the target tables were hand-created during the operation, they start exactly like Pattern 1's recreates: flag off, zero policies, grants ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial workflows&lt;/strong&gt; — one table dumped here, restored there, the original dropped in between — combine both hazards and are the most common way a single-table "cleanup" deletes its own protection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these paths warn. A restore completes successfully whether or not the resulting schema matches what you'd call protected, because "matches intent" was never a property dumps carry. The practical rules: prefer full-schema restores; when surgery is unavoidable, run the snapshot pair from the checklist below immediately after; and treat any environment rebuilt outside the migration pipeline as unauthorized until verified — the assumption that production equals what CI approved is precisely the gap &lt;a href=""&gt;staging-versus-production drift&lt;/a&gt; lives in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-query checklist that catches all four patterns
&lt;/h2&gt;

&lt;p&gt;This is the entire discipline, sized to fit in a migration template. Before the migration runs, snapshot the protection state:&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;-- protection_snapshot.sql&lt;/span&gt;
&lt;span class="k"&gt;select&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;relname&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;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policyname&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;policy_count&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&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;oid&lt;/span&gt; &lt;span class="o"&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;relnamespace&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;pg_policies&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schemaname&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="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&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;relname&lt;/span&gt;
&lt;span class="k"&gt;where&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&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;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&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;relname&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;relrowsecurity&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&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;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the migration runs, run it again. Three comparisons tell you everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Any new table with &lt;code&gt;rls_enabled = false&lt;/code&gt; or &lt;code&gt;policy_count = 0&lt;/code&gt;&lt;/strong&gt; — Patterns 1 and 4a.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any familiar table whose &lt;code&gt;policy_count&lt;/code&gt; dropped&lt;/strong&gt; without an explicit, intended policy deletion in the diff — Pattern 2's CASCADE signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any table where policies changed but the diff didn't mention authorization at all&lt;/strong&gt; — Pattern 4b's union effect, and anything else unexpected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Teams using migration tools can wire the snapshot pair into CI as a generated artifact: fail the build on unexplained deltas, require a one-line justification comment for intentional ones. The wiring is deliberately boring — capture to a file, diff against the previous capture, non-zero exit on unexplained change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; protection_snapshot.sql &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; after.txt
diff migrations/_protection_baseline.txt after.txt &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Protection inventory changed — justify or fix"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;after.txt migrations/_protection_baseline.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Committed alongside the migrations it guards, the baseline file makes protection drift as reviewable as any code change: the PR diff now &lt;em&gt;contains&lt;/em&gt; authorization consequences, in four columns, where a reviewer cannot miss them. The cost is seconds per migration; the alternative cost is measured in quarters, as &lt;a href=""&gt;teams who found the same regression twice&lt;/a&gt; tend to discover.&lt;/p&gt;

&lt;p&gt;For state that changes outside migrations — restores, dashboard edits, hotfix branches — no CI hook can see it. That residual window is exactly what continuous monitoring covers, and why our remediation flow (&lt;a href=""&gt;the SQL reference&lt;/a&gt;) pairs proposed fixes with ongoing checks rather than one-time cleanups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Doesn't pg_dump capture policies? Aren't restores safe?
&lt;/h3&gt;

&lt;p&gt;Schema dumps include policies, so a full dump-and-restore preserves them. The danger sits in partial workflows: data-only restores applied to freshly created tables, hand-built replacement tables during surgery, or snapshots predating a hardening migration. Restores reproduce whatever state existed — including states you were glad to leave behind.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I find which policies a planned DROP COLUMN would kill?
&lt;/h3&gt;

&lt;p&gt;Query &lt;code&gt;pg_policies&lt;/code&gt; for the column name inside &lt;code&gt;qual&lt;/code&gt; and &lt;code&gt;with_check&lt;/code&gt;, as shown in Pattern 2 — plus check &lt;code&gt;pg_depend&lt;/code&gt; for non-policy dependents. If several policies appear, plan their replacements in the same migration rather than accepting the CASCADE notice as an answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there ever a legitimate reason to disable RLS in a migration?
&lt;/h3&gt;

&lt;p&gt;Yes, narrowly: bulk-load sessions sometimes disable constraints and security temporarily for speed, re-enabling afterward within the same migration. Treat it like disabling triggers — acceptable when scoped, transactional, and restored; a red flag anywhere else. Your post-migration snapshot should show the flag back on, which is precisely what the checklist enforces.&lt;/p&gt;

&lt;h3&gt;
  
  
  We use an ORM that manages schema. Does this apply?
&lt;/h3&gt;

&lt;p&gt;Doubly. ORM-generated migrations are exactly where recreate-style refactors originate, and the tool's diff view shows tables and columns, not policy objects. Run the snapshot pair around ORM-generated migrations especially — they're the highest-volume source of Pattern 1 in the wild.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do views over a table change any of this?
&lt;/h3&gt;

&lt;p&gt;Views depend on their underlying tables the way policies do, so renames propagate cleanly — but replacing or dropping tables beneath views follows the same CASCADE mechanics as policies, and pre-Postgres-15 view semantics bypass row security entirely unless the view is declared &lt;code&gt;security_invoker&lt;/code&gt;. If your migration touches a table feeding views, extend the pre-flight query to include &lt;code&gt;pg_depend&lt;/code&gt;, and review &lt;a href="https://supabase.com/docs/guides/database/views" rel="noopener noreferrer"&gt;the views guide&lt;/a&gt; for the invoker setting.&lt;/p&gt;




&lt;p&gt;Check your schema's current protection inventory in minutes: &lt;a href=""&gt;run the free scan&lt;/a&gt; — paste your app URL and review findings with proposed remediation SQL for anything drifting.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vibeguard</category>
      <category>migrations</category>
      <category>that</category>
      <category>silently</category>
    </item>
    <item>
      <title>Reading Stripe's ledger like an accountant</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Wed, 23 Sep 2026 20:16:29 +0000</pubDate>
      <link>https://dev.to/veristria/reading-stripes-ledger-like-an-accountant-3p5i</link>
      <guid>https://dev.to/veristria/reading-stripes-ledger-like-an-accountant-3p5i</guid>
      <description>&lt;h1&gt;
  
  
  Reading Stripe's ledger like an accountant
&lt;/h1&gt;

&lt;p&gt;Every cent that moves through a Stripe account leaves exactly one trace: a BalanceTransaction row. This guide teaches finance teams to treat those rows as the general ledger — the object anatomy, the type vocabulary translated into journal-entry terms, and reconciling refunds against what should have happened rather than merely confirming that something did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start from the BalanceTransaction object
&lt;/h2&gt;

&lt;p&gt;If you run the close for a Connect platform, this object is yours before anyone else's — controllers and finance operations own its interpretation (&lt;a href="https://feeguard.dev/teams/controller" rel="noopener noreferrer"&gt;the controller's view&lt;/a&gt;). The anatomy is small enough to memorize; the table gives each field its accounting read.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;th&gt;Accounting read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;amount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signed integer, smallest currency unit&lt;/td&gt;
&lt;td&gt;Gross movement in or out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fee&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Positive integer&lt;/td&gt;
&lt;td&gt;Costs attached to this line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;net&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signed integer&lt;/td&gt;
&lt;td&gt;Cash impact; Stripe computes it as &lt;code&gt;amount - fee&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fee_details&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Itemized array&lt;/td&gt;
&lt;td&gt;The split behind &lt;code&gt;fee&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;currency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ISO code&lt;/td&gt;
&lt;td&gt;Never aggregate across these&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Enum&lt;/td&gt;
&lt;td&gt;The chart-of-accounts hint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Originating object ID&lt;/td&gt;
&lt;td&gt;The join key back to reality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;created&lt;/code&gt; / &lt;code&gt;available_on&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Timestamps&lt;/td&gt;
&lt;td&gt;Event date versus recognition date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;available&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Which clock the money is on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of it lives on the &lt;a href="https://docs.stripe.com/api/balance_transactions/object" rel="noopener noreferrer"&gt;BalanceTransaction object&lt;/a&gt;, which also carries a &lt;code&gt;reporting_category&lt;/code&gt; field grouping types for accounting use. The discipline that follows from the anatomy: book revenue from &lt;code&gt;amount&lt;/code&gt;, book cash from &lt;code&gt;net&lt;/code&gt;, and never book a payout lump as revenue — a payout is a movement of already-earned money, not income.&lt;/p&gt;

&lt;p&gt;The arithmetic is self-evident but worth pinning. A $100.00 payment with a $3.20 processing fee posts as one transaction where &lt;code&gt;amount&lt;/code&gt; = 10000¢ and &lt;code&gt;fee&lt;/code&gt; = 320¢:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net = amount - fee = 10000 - 320 = 9680 → $96.80 reaches the balance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One object, three numbers, and each answers a different question: what was billed, what it cost, what settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two clocks on the same money
&lt;/h2&gt;

&lt;p&gt;Every transaction exists on two timestamps. &lt;code&gt;created&lt;/code&gt; records when the event happened; &lt;code&gt;available_on&lt;/code&gt; records when its net becomes usable, because card payments land in &lt;code&gt;pending&lt;/code&gt; first and roll onto the available balance on a rolling schedule — typically about two business days, varying by country and risk profile. Payouts draw only from available funds (&lt;a href="https://docs.stripe.com/payouts" rel="noopener noreferrer"&gt;payouts&lt;/a&gt;), and both balances exist per account on Connect (&lt;a href="https://docs.stripe.com/connect/account-balances" rel="noopener noreferrer"&gt;account balances&lt;/a&gt;). The &lt;a href=""&gt;balance.available event&lt;/a&gt; fires at the moment of the transition if you want it pushed rather than polled.&lt;/p&gt;

&lt;p&gt;An illustrative timeline, dates assumed for the example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Ledger event&lt;/th&gt;
&lt;th&gt;Money state&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mon&lt;/td&gt;
&lt;td&gt;Charge created, &lt;code&gt;available_on&lt;/code&gt; = Wed&lt;/td&gt;
&lt;td&gt;Pending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wed&lt;/td&gt;
&lt;td&gt;Becomes available&lt;/td&gt;
&lt;td&gt;Usable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fri&lt;/td&gt;
&lt;td&gt;Payout created against available&lt;/td&gt;
&lt;td&gt;Leaving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next Mon&lt;/td&gt;
&lt;td&gt;Bank credit appears&lt;/td&gt;
&lt;td&gt;Reconciled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This clockwork explains most bank-recognition mismatches. The payout that hits the bank next Monday contains sales from the previous week; matching it against Monday's invoices fails by construction. Match payouts to bank lines by payout amount and arrival date, recognize revenue per your policy on the sales themselves, and treat &lt;code&gt;available_on&lt;/code&gt; as the recognition boundary between the two ledgers. When bank reconciliation disagrees with Stripe, timing is the default hypothesis and error the exception worth proving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The type vocabulary, translated
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; enum is the closest thing Stripe has to a chart of accounts, and the table below translates the commonly seen values into journal-entry terms. Grouped by role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Types&lt;/th&gt;
&lt;th&gt;Journal-entry meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Revenue side&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;charge&lt;/code&gt;, &lt;code&gt;payment&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Gross sales in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue side&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application_fee&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Platform commission income on Connect charges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contra&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;refund&lt;/code&gt;, &lt;code&gt;payment_refund&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Sales returns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contra&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application_fee_refund&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Commission given back to the seller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contra&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adjustment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Post-settlement corrections; investigate each one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Movement out&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;transfer&lt;/code&gt;, &lt;code&gt;payout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Funds to connected accounts; funds to bank&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Movement failed or returned&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payout_cancel&lt;/code&gt;, &lt;code&gt;payout_failure&lt;/code&gt;, &lt;code&gt;transfer_refund&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Movements that bounced; a reversal returning transferred funds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Movement in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;topup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Manually added funds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe-side fees&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stripe_fee&lt;/code&gt;, &lt;code&gt;stripe_fx_fee&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Processing and conversion costs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect machinery&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;reserve_transaction&lt;/code&gt;, &lt;code&gt;reserved_funds&lt;/code&gt;, &lt;code&gt;connect_collection_transfer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Risk holds; aged-negative collection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Treat the table as a working subset. The authoritative enumeration, with a sentence on what each value represents, sits on &lt;a href="https://docs.stripe.com/reports/balance-transaction-types" rel="noopener noreferrer"&gt;Stripe's balance transaction types reference&lt;/a&gt;, and rare values appear only on some accounts — an unfamiliar type is a prompt to look it up, not to guess.&lt;/p&gt;

&lt;p&gt;Two entries deserve special respect in month-end review. &lt;code&gt;adjustment&lt;/code&gt; means money moved after the fact for reasons ranging from dispute outcomes to corrections, and each one should trace to a cause you can name. &lt;code&gt;connect_collection_transfer&lt;/code&gt; means Stripe reached into platform reserves to zero out an aged negative connected-account balance — rare, material, and never a surprise if the negative balances were being watched.&lt;/p&gt;

&lt;h2&gt;
  
  
  One net, many GL lines: fee_details
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;net&lt;/code&gt; rarely maps to a single general-ledger line, and &lt;code&gt;fee_details&lt;/code&gt; is where the decomposition happens. This is a seller-side direct charge as the connected account sees it — $100.00 sale, standard US pricing assumption of 2.9% + $0.30, $10.00 application fee:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"balance_transaction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"fee"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1320&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"net"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8680&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"charge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"fee_details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stripe processing fee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stripe_fee"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Application fee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application_fee"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the arithmetic: &lt;code&gt;fee&lt;/code&gt; = 320 + 1000 = 1320, and &lt;code&gt;net&lt;/code&gt; = 10000 − 1320 = 8680. One ledger row, three GL destinations; the table shows how a controller might map it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GL line&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Revenue (gross)&lt;/td&gt;
&lt;td&gt;$100.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment processing expense&lt;/td&gt;
&lt;td&gt;$3.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform commission expense&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Net settled&lt;/td&gt;
&lt;td&gt;$86.80&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the check that 100.00 − 3.20 − 10.00 = 86.80. The platform side of the same economy splits across two transactions instead: a destination-charge sale nets $96.80 after only the Stripe fee, and the transfer to the seller posts separately at −$90.00, leaving margin of 96.80 − 90.00 = &lt;strong&gt;$6.80&lt;/strong&gt;. The commission itself is an ApplicationFee object with its own &lt;code&gt;amount&lt;/code&gt;, &lt;code&gt;amount_refunded&lt;/code&gt;, and &lt;code&gt;balance_transaction&lt;/code&gt; fields (&lt;a href="https://docs.stripe.com/api/application_fees/object" rel="noopener noreferrer"&gt;application fee object&lt;/a&gt;) — the fields every fee-reconciliation check eventually reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconstructing a refund from three ledger lines
&lt;/h2&gt;

&lt;p&gt;Refund flags state intent; the ledger states execution. Reading the cluster of transactions around a refund tells you which one you actually got.&lt;/p&gt;

&lt;p&gt;Take the canonical destination charge: $100.00 sale, $90.00 transfer, $10.00 application fee, platform margin $6.80. Now refund it in full with both &lt;code&gt;reverse_transfer=true&lt;/code&gt; and &lt;code&gt;refund_application_fee=true&lt;/code&gt;. Three ledger rows appear:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Source points at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;−10000¢&lt;/td&gt;
&lt;td&gt;&lt;code&gt;refund&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The refund object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;+9000¢&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transfer_refund&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The original transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;−1000¢&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application_fee_refund&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The fee refund&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sum the cluster: −10000 + 9000 − 1000 = −2000, so the refund-week cash effect is −$20.00. Stack it on the sale-time margin of $6.80 and the platform's lifetime position is 6.80 − 20.00 = &lt;strong&gt;−$13.20&lt;/strong&gt; while the seller ends up +$10.00 — the cluster reveals that flagging everything true over-compensates the seller on destination charges, because fee refunds compensate sellers, never buyers.&lt;/p&gt;

&lt;p&gt;Now the default signature: same refund, no flags. The ledger shows a single bare row — −10000¢ typed &lt;code&gt;refund&lt;/code&gt; — and nothing else. Lifetime position: 6.80 − 100.00 = &lt;strong&gt;−$93.20&lt;/strong&gt;, seller untouched at +$90.00. One line versus three lines is the entire difference between a deliberate unwind and money left on the table.&lt;/p&gt;

&lt;p&gt;That contrast is automatable without any judgment. For each &lt;code&gt;refund&lt;/code&gt; entry, expect siblings proportional to the refund ratio: a reversal near &lt;code&gt;round(refund_ratio × transfer_amount)&lt;/code&gt; and a fee refund near &lt;code&gt;round(refund_ratio × fee_amount)&lt;/code&gt;. Missing siblings are findings. Present siblings are policy, working as designed. The ledger answers not just "did we refund" but "did the refund do everything our policy promised."&lt;/p&gt;

&lt;h2&gt;
  
  
  Tying the ledger to the bank
&lt;/h2&gt;

&lt;p&gt;Three bridges connect the Stripe ledger to external reality, and each has a failure mode worth instrumenting.&lt;/p&gt;

&lt;p&gt;Payouts bridge to bank statements. Every payout groups the transactions inside it, and Stripe's reporting offers itemized balance-change and payout-reconciliation report types built for exactly this match (&lt;a href="https://docs.stripe.com/stripe-reports" rel="noopener noreferrer"&gt;Stripe reports&lt;/a&gt;). Match by amount and arrival date; investigate residuals rather than forcing them.&lt;/p&gt;

&lt;p&gt;Failed legs leave their own entries. A payout that bounces produces &lt;code&gt;payout_failure&lt;/code&gt; activity and a &lt;code&gt;payout.failed&lt;/code&gt; event; a refund that cannot reach the card reverses course and returns funds to your balance within up to about 30 days, exposed through &lt;code&gt;failure_balance_transaction&lt;/code&gt; on the refund (&lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;refunds&lt;/a&gt;). The ledger type &lt;code&gt;refund_failure&lt;/code&gt; marks the round trip. Neither failure closes anything until the return leg lands.&lt;/p&gt;

&lt;p&gt;Disputes reinstate as well as debit. A won case returns the held funds, announced by the &lt;code&gt;charge.dispute.funds_reinstated&lt;/code&gt; event, with the corresponding positive entries landing on the ledger (&lt;a href="https://docs.stripe.com/connect/disputes" rel="noopener noreferrer"&gt;Connect disputes&lt;/a&gt;). Book them against the original reserve, not as new income.&lt;/p&gt;

&lt;p&gt;Keeping all of this queryable year-round is a pipeline decision, not a spreadsheet one; syncing Stripe data into a warehouse makes every assertion in the next section a scheduled query (&lt;a href=""&gt;BigQuery sync&lt;/a&gt;), while export-driven teams can still systematize the CSV path (&lt;a href=""&gt;recovery from Excel exports&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Assertions worth automating
&lt;/h2&gt;

&lt;p&gt;Three classes of assertion turn the ledger from a record into a control. Run all three per currency, per day.&lt;/p&gt;

&lt;p&gt;The identity test proves completeness: opening available balance plus all non-payout activity minus payout totals must equal the closing available balance. In code:&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;closesBalanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="nx"&gt;txs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BalanceTransaction&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
 &lt;span class="nx"&gt;openingAvailable&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;payoutTotal&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;closingAvailable&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="nx"&gt;boolean&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;activity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;txs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;sum&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;t&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="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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;openingAvailable&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;activity&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;payoutTotal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;closingAvailable&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 false result means a missing or duplicated transaction — reconcile before anything else that day.&lt;/p&gt;

&lt;p&gt;The expectation test proves correctness, not just completeness: every refund cluster must satisfy the proportional formulas — reversal near ratio times transfer, fee refund near ratio times fee — and violations are findings regardless of how cleanly the identity test passes. An internally consistent ledger happily records a policy executed badly.&lt;/p&gt;

&lt;p&gt;The anomaly test proves nothing is hiding: any &lt;code&gt;adjustment&lt;/code&gt;, &lt;code&gt;reserve_transaction&lt;/code&gt;, or &lt;code&gt;connect_collection_transfer&lt;/code&gt; without a filed reason gets a ticket before the close finishes. These types are legitimate; unexplained, they are how surprises enter the books quietly.&lt;/p&gt;

&lt;p&gt;Teams running this manually today can compare their process against the &lt;a href=""&gt;month-end close pattern&lt;/a&gt; or the &lt;a href=""&gt;spreadsheet-based workflow&lt;/a&gt; as further reading — the assertions above are the part worth keeping no matter which tool executes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Corrections you will meet: failures, reinstatements, reserves
&lt;/h2&gt;

&lt;p&gt;A clean month still contains entries that exist purely to correct other entries. Reading them fast is most of the accountant's edge.&lt;/p&gt;

&lt;p&gt;Failed refunds come back to you. When a refund cannot reach the customer's instrument, the funds return to the balance that funded it — within up to roughly 30 days of the attempt (&lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;refunds&lt;/a&gt;) — and the Refund object carries &lt;code&gt;status: failed&lt;/code&gt; with a &lt;code&gt;failure_balance_transaction&lt;/code&gt; pointing at the return leg. In the ledger this appears as a fresh credit where you expected nothing; matching it against its failed refund prevents double-counting both the original refund and the return as activity.&lt;/p&gt;

&lt;p&gt;Won disputes reinstate funds. A dispute resolved in your favor triggers &lt;code&gt;charge.dispute.funds_reinstated&lt;/code&gt;, and the associated balance transaction restores what the freeze or debit had taken, including the partially-refunded-payment nuances Stripe documents. The reconciliation habit: every &lt;code&gt;dispute.created&lt;/code&gt; debit should eventually pair with either a &lt;code&gt;funds_reinstated&lt;/code&gt; credit or a permanent-loss entry, never neither.&lt;/p&gt;

&lt;p&gt;Reserve machinery lives on the platform side. &lt;code&gt;reserve_transaction&lt;/code&gt; entries appear when Stripe holds platform balance against a negative connected account and again when it releases that hold; &lt;code&gt;connect_collection_transfer&lt;/code&gt; appears when a 180-day-old connected negative is zeroed out of your reserves (&lt;a href="https://docs.stripe.com/connect/account-balances" rel="noopener noreferrer"&gt;account balances&lt;/a&gt;). Neither is an error; both are movements of YOUR money triggered by someone else's account, which makes them exactly the lines worth ticketing on sight.&lt;/p&gt;

&lt;p&gt;And then there is &lt;code&gt;adjustment&lt;/code&gt; — the catch-all. Genuine uses exist, but an unexplained adjustment without a filed reason is how surprises enter books quietly. Treat any adjustment you cannot narrate in one sentence as an open item until closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Balances per currency
&lt;/h2&gt;

&lt;p&gt;Multi-currency platforms read more than one column. The retrieve-balance call returns pending and available figures for each currency the account holds, so a USD-settled platform accumulating EUR application fees from cross-border destination charges sees separate EUR lines waiting there (&lt;a href="https://docs.stripe.com/api/balance/balance_retrieve" rel="noopener noreferrer"&gt;retrieve balance&lt;/a&gt;). Reconcile per currency before converting anything; mixing them at the totals level hides exactly the conversion deltas that &lt;a href=""&gt;FX-focused reviews&lt;/a&gt; go hunting for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/balance &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"sk_live_...:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each currency block answers independently: payouts draw from their own currency's available figure, refunds debit their own charge currency, and no automatic netting crosses the columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;net&lt;/code&gt; what lands in the bank?
&lt;/h3&gt;

&lt;p&gt;Not by itself. Each transaction's &lt;code&gt;net&lt;/code&gt; aggregates into the available balance, and the bank receives payouts — lumps of many nets. The bank line matches the payout amount, and the individual nets reconcile inside it. Treating any single transaction's net as a bank posting is the fastest way to break reconciliation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which date should I book a sale on?
&lt;/h3&gt;

&lt;p&gt;Pick a policy and apply it uniformly. Booking on &lt;code&gt;created&lt;/code&gt; aligns revenue with customer activity; &lt;code&gt;available_on&lt;/code&gt; marks when the cash became usable and drives the bank-side picture. Problems come from mixing the two conventions mid-year, not from either choice itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where do currency conversions show up?
&lt;/h3&gt;

&lt;p&gt;Inside the same balance transactions: when money converts, the transaction carries an &lt;code&gt;exchange_rate&lt;/code&gt; field explaining exactly how much landed (&lt;a href="https://docs.stripe.com/api/balance_transactions/object" rel="noopener noreferrer"&gt;BalanceTransaction object&lt;/a&gt;). Refunds convert at the live rate on refund day regardless of any quote locked at purchase time, and the original conversion fee is not returned (&lt;a href="https://docs.stripe.com/connect/currencies/fx-quotes-api" rel="noopener noreferrer"&gt;FX quotes&lt;/a&gt;) — both facts show up as spread between paired entries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do connected accounts see the same vocabulary?
&lt;/h3&gt;

&lt;p&gt;Yes — every account on Connect keeps its own ledger using the identical type enum, seen from its own side. The platform watches &lt;code&gt;transfer&lt;/code&gt; rows leave; the seller watches them arrive. Reconciling the two perspectives against each other is precisely how transfer-reversal gaps get found.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where do currency conversions show up?
&lt;/h3&gt;

&lt;p&gt;Inside the balance transactions themselves: when a charge's presentment currency differs from settlement, the transaction records the amounts and fees that produced the settled figure, so the conversion cost is embedded in &lt;code&gt;fee&lt;/code&gt;/&lt;code&gt;net&lt;/code&gt; rather than appearing as its own type. Comparing linked transactions across currencies is how the spread becomes visible — and why reconciling per currency first, then converting totals, keeps arithmetic honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own last 90 days
&lt;/h2&gt;

&lt;p&gt;Reading a ledger well tells you what happened; pairing every refund against what should have happened tells you what is missing. FeeGuard exists because that pairing runs silently on every refund your platform issues. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreturned application fee, and uncovered dispute loss with the amounts attached. You get the answer first; ongoing monitoring stays optional afterward.&lt;/p&gt;

&lt;p&gt;&lt;a href=""&gt;Run the free 90-day audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>reading</category>
      <category>stripes</category>
      <category>ledger</category>
    </item>
    <item>
      <title>The tenant-isolation test: five queries that prove your boundaries</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 11 Sep 2026 18:09:57 +0000</pubDate>
      <link>https://dev.to/veristria/the-tenant-isolation-test-five-queries-that-prove-your-boundaries-4gfg</link>
      <guid>https://dev.to/veristria/the-tenant-isolation-test-five-queries-that-prove-your-boundaries-4gfg</guid>
      <description>&lt;h1&gt;
  
  
  The tenant-isolation test: five queries that prove your boundaries
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Tenant isolation is provable in minutes, not asserted in meetings. This article gives developers five concrete probes — runnable against their own project with their own keys — where every expected result is stated in advance, so passing or failing is never a matter of interpretation.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is easy to believe your tenants are isolated because no customer has complained. That belief costs nothing until it's wrong, and when it's wrong it's expensive in the worst currency: someone else's data. The alternative is cheap enough to be embarrassing not to do — five requests, sent by you, against your own project, each with an unambiguous correct outcome. Run them once and you know your boundaries hold today. Wire them into CI and you know they held on every deploy since.&lt;/p&gt;

&lt;p&gt;Everything here uses only resources you own: your project URL, your public anon key, and two test accounts you created yourself. Nothing below requires or produces privileged access to anyone else's system — it is the same evidence-gathering the RowShield free scan automates from outside, done by hand.&lt;/p&gt;

&lt;p&gt;Why black-box probes at all, when you could read the policies? Because reading verifies intent while probing verifies &lt;em&gt;outcome&lt;/em&gt;, and the distance between those is where every incident in this site's catalog lives. Policies are code written by humans and migrations under pressure; requests are truth. A policy review that says "only owners" and a probe that returns Bob's rows are not in disagreement — one of them is wrong, and it has never been the probe. The five tests below are also deliberately cheap: no test framework, no fixtures beyond two accounts, nothing to maintain except attention. Cheap enough that "we should really check that" stops being a reason to skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: two accounts and one honest baseline
&lt;/h2&gt;

&lt;p&gt;Create two throwaway users through your own signup flow — call them Alice and Bob — and put each in their own workspace, project, or tenancy unit. Seed each with data you can recognize. Then establish the baseline both personas must agree on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/documents?select=id"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ALICE_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice should see exactly her fixture rows' IDs. If this baseline already surprises you — wrong count, missing rows, extra rows — stop here and investigate before running any negative tests, because everything downstream assumes you understand the happy path.&lt;/p&gt;

&lt;p&gt;One more preparatory habit matters: capture responses with headers (&lt;code&gt;curl -i&lt;/code&gt;) during your first run. Status codes carry half the diagnosis, and several of the five tests distinguish &lt;em&gt;correct rejection&lt;/em&gt; from &lt;em&gt;accidental success&lt;/em&gt; only by code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five probes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Test 1: the anonymous read
&lt;/h3&gt;

&lt;p&gt;What it proves: private tables are invisible to the identity everyone holds — no session, just the public key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/documents"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; &lt;code&gt;[]&lt;/code&gt; — an empty JSON array. (Or a 404-class error if the table isn't exposed at all, which is also a pass.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you see rows:&lt;/strong&gt; the table has no effective select policy for &lt;code&gt;anon&lt;/code&gt; — either RLS was never enabled, or policies simply don't mention the role. This is the single most common exposure our scans find, catalogued as &lt;a href="https://rowshield.dev/docs/rules/anon-table-readable" rel="noopener noreferrer"&gt;anon-table-readable&lt;/a&gt;, and it applies to every column including ones your UI never displays. A variation worth running per table: repeat with &lt;code&gt;select=id&amp;amp;limit=1&lt;/code&gt; across your full table inventory rather than assuming the answer transfers between tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 2: the forged write
&lt;/h3&gt;

&lt;p&gt;What it proves: a signed-in user cannot create rows attributed to someone else — the &lt;code&gt;WITH CHECK&lt;/code&gt; clause working as designed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/documents"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ALICE_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"workspace_id": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ALICE_WS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'", "title": "forged", "owner_id": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BOB_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; an HTTP error — PostgREST surfaces Postgres's policy rejection (&lt;code&gt;42501&lt;/code&gt;) as a &lt;code&gt;4xx&lt;/code&gt;, typically 403 — and no row appears afterward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the insert succeeds:&lt;/strong&gt; your insert path accepts planted rows, and the asymmetry that makes this dangerous deserves emphasis: check whether Alice can &lt;em&gt;read&lt;/em&gt; what she just wrote. If she can't (selects correctly filter to her own rows), the forged record sits invisible in Bob's scope — a planted object in another tenant's account that functional testing will never notice, because from the app's perspective nothing happened. This exact hole is the &lt;a href=""&gt;missing-WITH-CHECK signature&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 3: the cross-tenant read
&lt;/h3&gt;

&lt;p&gt;What it proves: knowing another tenant's identifiers buys nothing. Identity-based policies must beat ID-based queries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/documents?id=eq.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BOB_DOC_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ALICE_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; &lt;code&gt;[]&lt;/code&gt;. The row exists; Alice's request is well-formed; the policy must still refuse it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the row comes back:&lt;/strong&gt; your policies trust something other than the caller's verified identity — often a client-supplied &lt;code&gt;workspace_id&lt;/code&gt; parameter, or a policy comparing against the wrong column. Cross-tenant reads by identifier are the canonical multi-tenant leak, and unlike Test 2 they're silent in both directions: nothing errors, nothing is planted, data just crosses a boundary that your architecture diagram says exists. Run the same shape for every foreign key chain an outsider could guess: workspace IDs, slugs, sequential numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 4: the ownership transfer
&lt;/h3&gt;

&lt;p&gt;What it proves: users cannot hand rows out of their own scope — the update path's &lt;code&gt;WITH CHECK&lt;/code&gt; holding the line on the resulting row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/documents?id=eq.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ALICE_DOC_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ALICE_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"owner_id": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BOB_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; an HTTP error, and the row's owner unchanged when Alice reads it again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the update succeeds:&lt;/strong&gt; Alice just moved her row into Bob's account — or worse shapes exist depending on which clause is missing. An update policy with only &lt;code&gt;USING&lt;/code&gt; lets callers modify any row they can currently see into anything at all; the before-state is guarded while the after-state escapes. Our &lt;a href=""&gt;WITH CHECK deep-dive&lt;/a&gt; dissects why this half of update protection is the most commonly omitted clause in generated policy sets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 5: the silent delete
&lt;/h3&gt;

&lt;p&gt;What it proves: deletion respects the same boundaries reads do — and stays silent when it refuses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/documents?id=eq.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BOB_DOC_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ALICE_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Prefer: return=representation"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; an empty response — zero rows matched, zero returned — followed by Bob confirming his document still exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the row vanishes:&lt;/strong&gt; delete policies are wider than your model claims. Note the trap that makes this test necessary rather than obvious: a refused delete doesn't error, it matches nothing, so application code that treats "no error" as "deleted" will report success all day long while the boundary holds — and report success equally loudly on the day it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading failures like an engineer
&lt;/h2&gt;

&lt;p&gt;Each failure maps to exactly one clause of one policy, which is what makes this suite diagnostic rather than merely alarming:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test failing&lt;/th&gt;
&lt;th&gt;Clause implicated&lt;/th&gt;
&lt;th&gt;Typical fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — anonymous read&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USING&lt;/code&gt; on select policy (or RLS flag off)&lt;/td&gt;
&lt;td&gt;Enable RLS; write the anon decision deliberately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — forged write&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WITH CHECK&lt;/code&gt; on insert policy&lt;/td&gt;
&lt;td&gt;Add the ownership condition to the write check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — cross-tenant read&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USING&lt;/code&gt; comparing wrong column or trusting input&lt;/td&gt;
&lt;td&gt;Compare stored columns against &lt;code&gt;(select auth.uid())&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — ownership transfer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WITH CHECK&lt;/code&gt; on update policy&lt;/td&gt;
&lt;td&gt;Constrain the resulting row, not just the target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 — silent delete&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USING&lt;/code&gt; on delete policy&lt;/td&gt;
&lt;td&gt;Scope deletable rows by verified identity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two status codes carry most of the diagnosis. &lt;code&gt;403&lt;/code&gt; on writes means Postgres's policy rejection surfaced correctly — the database refused, your policies just need correcting. &lt;code&gt;200&lt;/code&gt; where you expected refusal means the statement executed; no amount of application-layer filtering downstream compensates for that, because the write already happened. And an empty-array pass on reads is only meaningful alongside its negative: if Test 3 returns &lt;code&gt;[]&lt;/code&gt; but so does Alice's &lt;em&gt;own&lt;/em&gt; baseline query, you haven't proven isolation, you've broken reads — which is why the baseline in setup comes first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variations worth running
&lt;/h2&gt;

&lt;p&gt;The five core probes generalize. Four variations extend coverage without new concepts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Column probing.&lt;/strong&gt; Repeat Test 1 with explicit sensitive columns — &lt;code&gt;select=owner_id,email,total_cents&lt;/code&gt; rather than defaults. A table can be "empty" under default column selection while leaking precisely the fields that matter when requested by name, if any view or generated mapping exposes them differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pagination walk.&lt;/strong&gt; On any anon-readable-but-supposedly-limited surface, walk pages: &lt;code&gt;?select=id&amp;amp;limit=1000&amp;amp;offset=0&lt;/code&gt;, then offset=1000, and so on. Row-level limits enforced in application code evaporate at the API layer unless policies or PostgREST configuration enforce them server-side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The storage sibling.&lt;/strong&gt; Buckets have their own policy system; the equivalent probe is requesting an object URL without credentials and checking the response. A public bucket fails it exactly like a naked table — same test shape, different subsystem (&lt;a href=""&gt;the storage rule&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second-tenant matrix.&lt;/strong&gt; Once two accounts work, three reveal more: add Carol as a member of &lt;em&gt;Alice's&lt;/em&gt; workspace and re-run Tests 3–5 expecting success through membership but failure through direct ID. Scale personas to match your model — one per distinct access level (owner, editor, viewer, admin, former member) — and the five tests stay identical per persona pair; only the number of baselines you record changes. Isolation is not secrecy from everyone — proving collaborators can reach shared data while outsiders can't is half the model, and it's the half teams test least.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it a habit
&lt;/h2&gt;

&lt;p&gt;A suite that runs once is an audit; a suite that runs always is a boundary. Three scheduling rules keep these probes alive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;After every migration that touches tables or policies&lt;/strong&gt;, run all five against staging. This is where regressions get caught while their cause is still in review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a schedule against production&lt;/strong&gt; — monthly is defensible, weekly is better — because restores and dashboard edits don't travel through CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With an owner.&lt;/strong&gt; Unowned checks rot. The table above fits in a README; a name next to it fits in a standup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Teams that outgrow hand-running wire the five requests into their existing test runner — each is a single HTTP call with an assertion, translatable to any stack in minutes. The investment converts isolation from an annual question into a continuous answer, which is the difference between hoping and knowing that the boundaries your product promises are the boundaries your database enforces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring the run
&lt;/h2&gt;

&lt;p&gt;Five tests, binary outcomes, no judgment calls. Run them in order — each builds on the previous one's passing state, and the sequence takes less time than the coffee it pairs with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;th&gt;Fail means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Anonymous read&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Table open to the world&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Forged write&lt;/td&gt;
&lt;td&gt;Rejected (4xx)&lt;/td&gt;
&lt;td&gt;Rows can be planted into others' scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Cross-tenant read&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tenant boundary crossed by ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Ownership transfer&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;td&gt;Rows can leave their tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Silent delete&lt;/td&gt;
&lt;td&gt;Zero effect, row intact&lt;/td&gt;
&lt;td&gt;Deletion crosses tenants&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any failure earns a specific conversation, not a general worry — each test maps to one policy clause, and the remediation is usually a single corrected statement. The in-database versions of these same checks (with exact SQLSTATE assertions and pgTAP wrappers) live in &lt;a href=""&gt;our testing guide&lt;/a&gt;, and the natural next step is wiring the whole set to run after every migration, so isolation regressions surface in pull requests instead of support tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these five don't cover
&lt;/h2&gt;

&lt;p&gt;Honest scoping, again: the suite proves the API surface for two personas on the tables you thought to test. It doesn't cover Storage buckets (their own policy system, checked separately), Realtime channels, service-role paths, or tables nobody remembered to include. It also proves today: a migration next week can invalidate every green checkmark without touching your test file. That's the gap continuous monitoring exists to close — the five probes tell you where your boundaries stand right now; monitoring tells you whether they moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can't I just run these once in staging?
&lt;/h3&gt;

&lt;p&gt;Staging answers them for staging. Environments diverge — different migration histories, manual fixes, restore accidents — and production is where exposure is real. Run first in staging during development, then run the identical set against production as its own event; divergence between the two results is itself a finding about environment drift.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need real JWTs for the authenticated tests?
&lt;/h3&gt;

&lt;p&gt;You need valid tokens for accounts you control, obtained through your own auth flow — which is deliberately part of the test, since token issuance is step one of the authorization chain. Expired or malformed tokens should fail with 401s; if they don't, that's a sixth finding worth having.&lt;/p&gt;

&lt;h3&gt;
  
  
  One of my tables is genuinely public-read. Should Test 1 fail there?
&lt;/h3&gt;

&lt;p&gt;No — Test 1's expectation is per-table intent, not blanket secrecy. For genuinely public tables, the expectation shifts: anon may read published columns but must not read unpublished ones (check with a known-draft row) and must fail writes. Deliberate public access still deserves deliberate proofs; &lt;a href=""&gt;anonymous access done deliberately&lt;/a&gt;'s cluster covers the design side.&lt;/p&gt;

&lt;h3&gt;
  
  
  We found a failure. Fix the policy or fix the feature?
&lt;/h3&gt;

&lt;p&gt;Fix the policy, then fix whatever breaks. Every one of these failures means the database admitted something your product never promised anyone — features built on that admission were borrowing unowned access. The remediation SQL for each failure class is small and mechanical (&lt;a href=""&gt;the findings reference&lt;/a&gt;) includes worked examples, and the breakage it causes in your app is the sound of assumptions being paid off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should views be tested separately?
&lt;/h3&gt;

&lt;p&gt;Yes — a view is another API surface with its own exposure semantics. Depending on Postgres version and the view's &lt;code&gt;security_invoker&lt;/code&gt; setting, it can bypass underlying table policies entirely or inherit them; probe the view endpoint as anon and as Bob exactly as you would a table. The &lt;a href="https://supabase.com/docs/guides/database/views" rel="noopener noreferrer"&gt;views guide&lt;/a&gt; covers which behavior your project's views have.&lt;/p&gt;




&lt;p&gt;Run the &lt;a href=""&gt;free scan&lt;/a&gt; on your own project — paste your app URL, and see your anonymous surface as the outside world sees it, with proposed remediation for every finding.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vibeguard</category>
      <category>the</category>
      <category>tenantisolation</category>
      <category>test</category>
    </item>
    <item>
      <title>Auditing a Supabase project in one afternoon</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Wed, 09 Sep 2026 16:38:52 +0000</pubDate>
      <link>https://dev.to/veristria/auditing-a-supabase-project-in-one-afternoon-1ia4</link>
      <guid>https://dev.to/veristria/auditing-a-supabase-project-in-one-afternoon-1ia4</guid>
      <description>&lt;h1&gt;
  
  
  Auditing a Supabase project in one afternoon
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;You don't need tooling to know where your Supabase security stands - you need four hours and the right questions in the right order. This is the complete manual audit: every catalog query, how to read each result, and the outside probes that turn findings into proof.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;RowShield exists because this audit is worth automating and monitoring. But the manual version matters for different reasons: it teaches you what the automation watches, it works today with zero setup, and when a scan does flag something, understanding the query behind the finding means understanding the fix. This article is the audit we run by hand — scheduled as an afternoon, structured so each hour's output feeds the next.&lt;/p&gt;

&lt;p&gt;Everything below reads or probes your own project using your own access. Every SQL statement runs against current Postgres; all were executed during this article's preparation. By the end you'll have a findings document with evidence attached — not a vibe about your security, a list with proof per line, sorted by what deserves fixing first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The afternoon plan
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hour 1&lt;/td&gt;
&lt;td&gt;Catalog inventory: tables, policies, grants, functions, views, buckets&lt;/td&gt;
&lt;td&gt;Protection inventory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour 2&lt;/td&gt;
&lt;td&gt;Read the inventory: shapes, unions, gaps&lt;/td&gt;
&lt;td&gt;Findings list (structural)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour 3&lt;/td&gt;
&lt;td&gt;Outside probes: anon surface + two-account battery&lt;/td&gt;
&lt;td&gt;Findings list (behavioral)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour 4&lt;/td&gt;
&lt;td&gt;Storage, functions, keys; write up everything&lt;/td&gt;
&lt;td&gt;Prioritized remediation worklist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bring to the session: your project's SQL editor access, the public anon key, two test accounts if the app has authentication (create them through signup if not), and a document open for findings. Nothing else — no special tooling, no credentials beyond what you already hold.&lt;/p&gt;

&lt;p&gt;The order matters more than the clock. Inventory before reading prevents anchoring on whatever the app's UI shows; structural reading before probing tells you which probes matter most; probes last because they confirm rather than explore. Teams that start with probing often stop at the first scary result and never learn what else the catalog was trying to tell them.&lt;/p&gt;

&lt;p&gt;A word on scope before starting: this audit covers authorization posture — what's reachable, by whom, under what rules — rather than code vulnerabilities, dependency patching, or infrastructure hardening. Those belong to other checklists. The authorization layer earns its own audit because it changes on every deploy and fails in ways nothing else surfaces; treat this afternoon as the recurring core, with other security reviews layered around it at their own cadences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour one: inventory the protection surface
&lt;/h2&gt;

&lt;p&gt;Open the SQL editor and run five queries. First, tables and their RLS flags:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&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;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&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;relforcerowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;force_rls&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&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;oid&lt;/span&gt; &lt;span class="o"&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;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&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;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&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;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, every policy in full:&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;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permissive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, grants — which client-facing roles can do what:&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;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege_type&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role_table_grants&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourth, privilege-elevating functions:&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proconfig&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&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;oid&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="n"&gt;pronamespace&lt;/span&gt;
&lt;span class="k"&gt;where&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifth, views and their invocation semantics:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&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;reloptions&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&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;oid&lt;/span&gt; &lt;span class="o"&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;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&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;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'v'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the project uses Storage, add its buckets and object policies to the pile (&lt;code&gt;storage.buckets&lt;/code&gt;, then &lt;code&gt;pg_policies&lt;/code&gt; filtered to &lt;code&gt;schemaname = 'storage'&lt;/code&gt;). Export all of it — copy to a document, literally. Hour two reads from this artifact, and hour four's write-up cites it as evidence.&lt;/p&gt;

&lt;p&gt;To make the reading concrete, here is the first query's output from the fabricated example project used across RowShield articles, annotated the way you should annotate your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      relname      | rls_enabled | force_rls
-------------------+-------------+-----------
 documents         | t           | f
 notifications     | f           | f      &amp;lt;-- FINDING: open table
 workspace_members | t           | f
 workspaces        | t           | f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every column earns its keep. &lt;code&gt;rls_enabled = false&lt;/code&gt; means the API serves that table to anyone holding your public key — no policy discussion needed, exposure exists today. &lt;code&gt;force_rls = false&lt;/code&gt; is normal (owner access for migrations) but worth knowing per table: it names which tables would still bypass policies if someone connects as the owner. Annotate every anomaly inline as you go; memory doesn't survive to hour two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour two: read what you found
&lt;/h2&gt;

&lt;p&gt;The inventory answers six questions, each mapped to a verdict:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Where to look&lt;/th&gt;
&lt;th&gt;Bad answer looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Which tables are open?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rls_enabled&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;Any &lt;code&gt;false&lt;/code&gt; on a non-public-by-design table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which writes lack constraints?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;with_check&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;NULL under INSERT/UPDATE/ALL policies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which tables carry tautologies?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;qual&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;Constant &lt;code&gt;true&lt;/code&gt; expressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where do unions widen?&lt;/td&gt;
&lt;td&gt;Same table+cmd grouping&lt;/td&gt;
&lt;td&gt;Three-plus PERMISSIVE policies per command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What bypasses row security?&lt;/td&gt;
&lt;td&gt;Function list&lt;/td&gt;
&lt;td&gt;Definer functions without pinned search_path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What elevates indirectly?&lt;/td&gt;
&lt;td&gt;Views' reloptions&lt;/td&gt;
&lt;td&gt;Views missing &lt;code&gt;security_invoker = true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reading technique for the policy dump: group rows mentally by &lt;code&gt;(tablename, cmd)&lt;/code&gt; and read each group as one OR expression, since &lt;a href="https://rowshield.dev/solutions/multiple-policies-permissive-union" rel="noopener noreferrer"&gt;permissive policies combine&lt;/a&gt; by union. A group whose members disagree about scope isn't contradictory — it's as wide as its widest member. Note any identity comparison that references something other than &lt;code&gt;(select auth.uid())&lt;/code&gt;: request parameters and client-shaped columns don't count as verified identity.&lt;/p&gt;

&lt;p&gt;A worked mini-read shows the method. Suppose some project's dump contains, for one table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;policyname            | permissive | cmd    | qual                          | with_check
----------------------+------------+--------+-------------------------------+-----------
invoices_select_owner | PERMISSIVE | SELECT | uid() = owner_id              |
invoices_select_any   | PERMISSIVE | SELECT | true                          |
invoices_insert_own   | PERMISSIVE | INSERT |                               | false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines, three verdicts. The &lt;code&gt;any&lt;/code&gt; tautology makes the owner policy redundant for reads — every authenticated user matches every row already, so the careful clause contributes nothing (and its presence may mislead reviewers into thinking reads are scoped). The insert policy's check of literal &lt;code&gt;false&lt;/code&gt; means inserts always fail — likely a debugging leftover that broke a feature someone "fixed" elsewhere, worth investigating before deleting. Two findings from three rows, both with evidence attached, neither requiring any judgment beyond the reading rules above.&lt;/p&gt;

&lt;p&gt;Mark each finding high/medium/low as you go — high for anything exposing data now (open tables, unconstrained writes), medium for structure that will misbehave under pressure (tautologies, unhygienic definer functions), low for hygiene (naming, comments, bare auth calls). Severity triage during reading beats severity debate later.&lt;/p&gt;

&lt;p&gt;Also record the &lt;em&gt;absence&lt;/em&gt; findings while reading: tables with zero policies despite being enabled, buckets with no policies at all, views nobody can explain. Absence findings age differently from defect findings — they're often intentional interim states that quietly became permanent — and they're precisely what a fresh reader catches that the schema's author no longer sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour three: probe from outside
&lt;/h2&gt;

&lt;p&gt;Structural findings predict exposure; probes prove it. Two batteries, both framed entirely within your own project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The anonymous sweep.&lt;/strong&gt; With only your public key, GET every exposed table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://YOUR-PROJECT.supabase.co/rest/v1/TABLE_NAME"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty array: pass. Rows: a finding, already half-documented from hour two. Also try one insert attempt per sensitive table expecting rejection — anonymous &lt;em&gt;writes&lt;/em&gt; are rarer than reads but strictly worse. If your model includes deliberate public reads (published listings and the like), verify their filters instead: draft rows must stay absent, internal columns must stay out of responses.&lt;/p&gt;

&lt;p&gt;Interpreting responses is mechanical once you've seen each shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Protected — or genuinely empty; note which and confirm later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows returned&lt;/td&gt;
&lt;td&gt;Open window, live now; capture the response as evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404 / "relation not found" error&lt;/td&gt;
&lt;td&gt;Table not API-exposed; fine for private tables, check why if public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401/403 on an attempted anonymous insert&lt;/td&gt;
&lt;td&gt;Pass — write path rejects unauthenticated callers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical notes. First, keep requests modest — a handful of rows per table proves exposure without bulk-downloading anything, which matters both ethically and for your own logs. Second, run probes against production, not staging: staging answers whether your pipeline produces protection, production answers whether protection currently exists, and they differ more often than teams expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two-account battery.&lt;/strong&gt; With test accounts Alice and Bob, run the five adversarial probes — anonymous read, forged write, cross-tenant read, ownership transfer, silent delete — exactly as specified in &lt;a href="https://rowshield.dev/blog/three-rls-bugs-that-look-like-one" rel="noopener noreferrer"&gt;the tenant-isolation playbook&lt;/a&gt;. Each probe has a binary expected outcome; record actual outcomes beside expectations. Where hour two predicted defects, these probes supply behavioral proof; where probes fail unexpectedly, hour two's inventory explains why. The two halves of the audit corroborate each other, and disagreements between them are themselves findings — they mean something mediates access outside the obvious path.&lt;/p&gt;

&lt;p&gt;Budget roughly twenty minutes per sensitive table for the full battery, less once practiced. Prioritize tables by what a breach of them means — user credentials and personal data outrank configuration tables — so if the afternoon compresses, the most important tables already ran their probes. The battery is also the audit's most transferable artifact: the same five requests run against any future project, unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hour four: storage, functions, keys
&lt;/h2&gt;

&lt;p&gt;Three remaining surfaces, each with its own quick check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buckets:&lt;/strong&gt; list them with visibility flags. Public buckets get reviewed as published content — is everything inside genuinely meant to be URL-accessible? Private buckets get their own policy review, path-scoping patterns especially (&lt;a href="https://rowshield.dev/docs/rules/public-bucket-exposure" rel="noopener noreferrer"&gt;the own-folder pattern&lt;/a&gt; and its neighbors). Check for the orphan case too: buckets with no policies at all, which behave as write-voids — safe from anonymous access but unusable by legitimate users, usually signaling an abandoned feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Functions:&lt;/strong&gt; for each definer function from hour one, call it as a restricted user and compare results to what that user could derive through policies alone. Pin &lt;code&gt;search_path&lt;/code&gt; wherever missing. Confirm execute privileges match intent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keys:&lt;/strong&gt; grep built assets and environment files for service-grade material, then fetch your deployed site's JavaScript and search it too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"sb_secret_"&lt;/span&gt; dist/ .next/static/ build/ 2&amp;gt;/dev/null
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ri&lt;/span&gt; &lt;span class="s2"&gt;"service_role"&lt;/span&gt; .env&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key material anywhere browser-reachable converts this afternoon into an incident-response morning — rotate first, investigate after (&lt;a href="https://rowshield.dev/solutions/leaked-service-role-rotation" rel="noopener noreferrer"&gt;rotation guidance&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Then spend the last thirty minutes writing. Per finding: what's exposed, catalog or probe evidence, proposed fix as concrete SQL, affected features for QA awareness. Sort high-to-low. That document is the audit's deliverable — and next quarter's baseline. It is also the step most easily dropped once the scary finding is fixed, which is how a repeatable program degrades back into an ad-hoc scramble with nothing to compare against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where manual ends and monitoring begins
&lt;/h2&gt;

&lt;p&gt;An afternoon audit is a snapshot; snapshots age. Migrations ship weekly, dashboards invite hotfixes, restores replace state silently — every channel that makes audits necessary also makes them stale. Re-running this whole sequence monthly is realistic; after every deploy is not, which is precisely the gap automation fills.&lt;/p&gt;

&lt;p&gt;The mapping between this article's steps and continuous coverage is direct: hour one's inventory becomes CI assertions; hour three's probes become scheduled external scans; hour four's key checks become build-time scanning. RowShield automates exactly those translations — &lt;a href="https://rowshield.dev/solutions/free-supabase-rls-audit" rel="noopener noreferrer"&gt;the free scan&lt;/a&gt; covers the outside-probe layer immediately, catalog monitoring extends it — while the judgment calls (is this public bucket intentional? should editors see drafts?) remain yours regardless of tooling.&lt;/p&gt;

&lt;p&gt;Run this audit manually once and you'll understand every automated finding forever after. That's not a consolation prize for lacking tooling — it's the reason the tooling's findings deserve trust. And when the next audit comes around, compare against the last one's document: deltas between snapshots are the drift narrative in miniature, each line either a deliberate change you can name or a finding that arrived uninvited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is one afternoon really enough?
&lt;/h3&gt;

&lt;p&gt;For small-to-medium projects — say, up to forty tables — yes, comfortably: most time goes to reading and writing up, not querying. Larger schemas split naturally across days by schema area, with the same per-area flow. What doesn't fit in an afternoon is fixing; this produces the prioritized list, and fixes schedule from there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need production access, or is staging enough?
&lt;/h3&gt;

&lt;p&gt;Both, ideally — but production is where truth lives. Staging validates upcoming changes; production holds the accumulated drift of every manual edit and restore since. If forced to choose one, audit production first; staging diverges from it in ways that matter less than the reverse.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if I find something alarming mid-afternoon?
&lt;/h3&gt;

&lt;p&gt;Handle by class. Exposure-that-is-happening-now (open tables, leaked keys): pause the audit, contain, resume — the containment patterns are short and this document's earlier sections link them. Everything else waits for the write-up; alarming-but-contained findings lose nothing from a day's delay in fixing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I audit a project I can't run locally?
&lt;/h3&gt;

&lt;p&gt;Everything in hours one and two needs only SQL editor access to the live project — read-only catalog queries, safe on production. Hour three's probes hit public endpoints by design. Only fixes require the usual deploy discipline; an audit itself never modifies state, which is worth stating explicitly when requesting access to someone else's project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the audit include checking my auth configuration too?
&lt;/h3&gt;

&lt;p&gt;Yes as a fifth quarter-hour: confirm email confirmation requirements, redirect allowlists, and whether unused OAuth providers are disabled. This article scopes to the database and surfaces because that's RowShield's home turf, but an afternoon audit that touches keys might as well confirm the identity layer's basic posture — most of it is reading settings screens.&lt;/p&gt;




&lt;p&gt;Prefer the automated starting point? &lt;a href="https://rowshield.dev/audit" rel="noopener noreferrer"&gt;Run the free scan&lt;/a&gt; — paste your app URL for the outside-probe layer of this audit instantly, then bring the results to your manual session.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vibeguard</category>
      <category>auditing</category>
      <category>a</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Supabase RLS: A 200 Response Does Not Prove Data Exposure</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Tue, 08 Sep 2026 01:55:01 +0000</pubDate>
      <link>https://dev.to/veristria/supabase-rls-a-200-response-does-not-prove-data-exposure-4ce6</link>
      <guid>https://dev.to/veristria/supabase-rls-a-200-response-does-not-prove-data-exposure-4ce6</guid>
      <description>&lt;p&gt;A public table endpoint can return HTTP 200 with an empty JSON array. That proves reachability, not data exposure.&lt;/p&gt;

&lt;p&gt;The useful question is whether the policies return the right rows for each identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small verification matrix
&lt;/h2&gt;

&lt;p&gt;Test the same read-only query as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an anonymous visitor&lt;/li&gt;
&lt;li&gt;the row owner&lt;/li&gt;
&lt;li&gt;an authenticated non-owner&lt;/li&gt;
&lt;li&gt;a service role, kept strictly server-side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For every path, record the status, row count, and whether returned records belong to the expected tenant. Repeat the matrix after each migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the result means
&lt;/h2&gt;

&lt;p&gt;An empty anonymous result can be exactly correct. A non-owner receiving another tenant's row is evidence of a policy problem. Treat those as different findings.&lt;/p&gt;

&lt;p&gt;Do not use destructive test queries against production. Start with read-only checks in a controlled environment and document what was tested.&lt;/p&gt;

&lt;p&gt;You can run a free, read-only RLS check at &lt;a href="https://rowshield.dev/audit" rel="noopener noreferrer"&gt;https://rowshield.dev/audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The check is evidence, not a guarantee: application logic, privileged server paths, and changes made after the test still need review.&lt;/p&gt;

</description>
      <category>supabase</category>
    </item>
    <item>
      <title>RLS policies do not fail. They drift.</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:56:17 +0000</pubDate>
      <link>https://dev.to/veristria/rls-policies-do-not-fail-they-drift-34ae</link>
      <guid>https://dev.to/veristria/rls-policies-do-not-fail-they-drift-34ae</guid>
      <description>&lt;h1&gt;
  
  
  RLS policies do not fail. They drift.
&lt;/h1&gt;

&lt;p&gt;Row-level security has an unusual failure mode. A policy does not break in the way code breaks. It does not throw, it does not fail a test, it does not appear in an error log. It keeps running exactly as written, and at some point what it was written for stops being what it does.&lt;/p&gt;

&lt;p&gt;The schema moved. The policy did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the drift
&lt;/h2&gt;

&lt;p&gt;Here is a policy that is completely correct on the day it is written.&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"own_documents"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&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;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row in &lt;code&gt;documents&lt;/code&gt; has an owner. A user reads their own. Nothing else. Correct.&lt;/p&gt;

&lt;p&gt;Three months later the product grows a sharing feature. A &lt;code&gt;document_collaborators&lt;/code&gt; table appears, and a new query joins it. The policy above is still there, still parses, still runs — and now the team writes a second policy to cover the new access path, because the first one does not.&lt;/p&gt;

&lt;p&gt;Two months after that, someone adds an &lt;code&gt;organisation_id&lt;/code&gt; column to &lt;code&gt;documents&lt;/code&gt; and a dashboard that lists documents by organisation. The dashboard returns nothing, because &lt;code&gt;own_documents&lt;/code&gt; filters by &lt;code&gt;owner_id&lt;/code&gt;. So a third policy is added for organisation members.&lt;/p&gt;

&lt;p&gt;Nothing here is negligent. Every step was a reasonable response to a real requirement. But &lt;code&gt;documents&lt;/code&gt; now has three overlapping &lt;code&gt;select&lt;/code&gt; policies, and Postgres combines multiple permissive policies with &lt;code&gt;OR&lt;/code&gt;. The effective access rule is the &lt;strong&gt;union&lt;/strong&gt; of all three — which is not what any of them says on its own, and is not something anybody has written down.&lt;/p&gt;

&lt;p&gt;That is drift. Not a broken policy: a set of individually correct policies whose combination nobody has evaluated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways it happens
&lt;/h2&gt;

&lt;p&gt;In practice, almost all of it comes from four events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new table ships with no policy at all.&lt;/strong&gt; If RLS is not enabled on a table, the anon key reads it. This is the loudest version of the problem and still the most common, because enabling RLS is a separate step from creating the table, and a table created by a migration written at speed does not always get both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A column widens what an existing policy exposes.&lt;/strong&gt; The policy filters rows, not columns. Adding a column to a table that is already readable makes that column readable too — including the one holding an email address, an internal note or a partially masked identifier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new access path routes around the policy.&lt;/strong&gt; A view, an RPC or a security-definer function that queries the table on the caller's behalf. Postgres views do not inherit the underlying table's RLS unless they are explicitly set up to; a security-definer function bypasses it by design. Both are legitimate tools, and both are ways for a row to arrive somewhere the policy never approved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policies accumulate.&lt;/strong&gt; As above. Each addition is safe in isolation and the union is never reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reading the migrations does not catch it
&lt;/h2&gt;

&lt;p&gt;The instinct is to review this statically: read the policy files, reason about the schema, check the logic. It does not work well, for the same reason reading code is a weak substitute for running it.&lt;/p&gt;

&lt;p&gt;A policy's behaviour depends on the current schema, the current set of other policies on the same table, the current role, and the current contents of &lt;code&gt;auth.uid()&lt;/code&gt;. Reasoning about the union of three policies against a schema that has changed twice since they were written is exactly the kind of thing humans get wrong — and it has to be redone every time any of those inputs changes.&lt;/p&gt;

&lt;p&gt;The alternative is to ask the database. Not "what does this policy say", but "as this role, with this token, can I read this row". That question has a definite answer, it is cheap to ask, and it is correct by construction, because it is the same code path a real request takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check, in order
&lt;/h2&gt;

&lt;p&gt;If you want to do this by hand today, this is the order that finds the most in the least time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every table with RLS disabled.&lt;/strong&gt; Query &lt;code&gt;pg_tables&lt;/code&gt; for &lt;code&gt;rowsecurity = false&lt;/code&gt; in your public schema. Each one is readable by anyone holding the anon key, which is in your frontend bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every table with RLS enabled and no policies.&lt;/strong&gt; This is the reverse failure: RLS on, no policy, everything denied. Usually it produces a bug report rather than a breach, but it tells you the table was set up in a hurry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tables with more than one permissive policy for the same operation.&lt;/strong&gt; These are the union cases. Write down what the combination actually permits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views and security-definer functions that touch protected tables.&lt;/strong&gt; Check each one for whether it is enforcing RLS or bypassing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then test it.&lt;/strong&gt; For each protected table, hold an anon token and a token for a user who should not have access, and try to read a row. The result is the only answer that counts.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;RLS is a good mechanism. It is enforced in the right place, it is hard to bypass by accident from application code, and it survives a careless client.&lt;/p&gt;

&lt;p&gt;What it does not do is notice when the world around it changes. The policy you wrote in April is still doing precisely what you asked it to in April. Whether that is still the right thing is a question somebody has to keep asking — and the honest way to ask it is to run the query and look at what comes back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rowshield.dev/audit" rel="noopener noreferrer"&gt;Run a free audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RowShield is an independent product with no affiliation to, or endorsement from, Supabase.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>rls</category>
    </item>
    <item>
      <title>How to audit 90 days of platform refunds by hand</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:50:02 +0000</pubDate>
      <link>https://dev.to/veristria/how-to-audit-90-days-of-platform-refunds-by-hand-5526</link>
      <guid>https://dev.to/veristria/how-to-audit-90-days-of-platform-refunds-by-hand-5526</guid>
      <description>&lt;h1&gt;
  
  
  How to audit 90 days of platform refunds by hand
&lt;/h1&gt;

&lt;h1&gt;
  
  
  How to audit 90 days of platform refunds by hand
&lt;/h1&gt;

&lt;p&gt;How much money did the last 90 days of refunds fail to return — to your platform, or to the sellers on it? This guide walks through the complete manual answer: the API queries, the spreadsheet, the two formulas, and a disputes pass. Everything runs on a restricted read-only key. No product is required at any step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope and inputs
&lt;/h2&gt;

&lt;p&gt;The audit asks two questions of every refund issued in the trailing 90 days. Did the transfer get reversed when your refund policy says it should have been? And did the application fee come back when policy says it should? Two formulas answer both, and the audit's output is one number per gap class, in cents, ready to sum.&lt;/p&gt;

&lt;p&gt;Anchor the window at run time. Ninety days is 7,776,000 seconds, so the lower bound in Unix seconds is &lt;code&gt;now − 7,776,000&lt;/code&gt;. Treat the window as half-open — everything with &lt;code&gt;created &amp;gt;= start&lt;/code&gt; and &lt;code&gt;created &amp;lt; end&lt;/code&gt; — so reruns on consecutive days never double-count a boundary refund.&lt;/p&gt;

&lt;p&gt;Two extraction routes exist. The Dashboard route exports payments filtered to refunded status and joins everything in a spreadsheet. The API route lists refunds directly. Prefer the API route: the joins this audit needs run on object IDs — &lt;code&gt;transfer&lt;/code&gt;, &lt;code&gt;application_fee&lt;/code&gt;, reversal records — and exports make those joins painful in ways the API makes trivial.&lt;/p&gt;

&lt;p&gt;Access needs less privilege than the task sounds like it should. Stripe supports restricted API keys scoped per resource, with read or write access chosen per resource, so a read-only key over the relevant families audits your money while holding no money-movement rights at all (&lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;keys&lt;/a&gt;). Never paste a live secret key into a script; read it from an environment variable even for a one-off.&lt;/p&gt;

&lt;p&gt;Five object families feed the audit; the table shows what each contributes and the minimum access it needs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Object family&lt;/th&gt;
&lt;th&gt;What it contributes&lt;/th&gt;
&lt;th&gt;Minimum access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Refunds (&lt;code&gt;/v1/refunds&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;The refund rows themselves: amount, currency, dates&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Charges (&lt;code&gt;/v1/charges&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Parent amount, &lt;code&gt;transfer&lt;/code&gt; ID, &lt;code&gt;application_fee&lt;/code&gt; ID&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transfers and their reversals&lt;/td&gt;
&lt;td&gt;What was pulled back from sellers, and when&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application fees and their refunds&lt;/td&gt;
&lt;td&gt;Whether the commission came back&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disputes&lt;/td&gt;
&lt;td&gt;Lost cases, which need their own pass below&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want this same procedure formatted as a step-by-step companion, the &lt;a href="https://feeguard.dev/support/su-03-running-your-historical-scan-90-day-lookback" rel="noopener noreferrer"&gt;90-day lookback walkthrough&lt;/a&gt; follows the identical order: extract, transform, score, sum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extract: the queries
&lt;/h2&gt;

&lt;p&gt;Start by listing refunds created inside the window. The &lt;code&gt;created&lt;/code&gt; filter takes Unix seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.stripe.com/v1/refunds?created[gte]=1771977600&amp;amp;created[lt]=1779753600&amp;amp;limit=100"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example bounds cover 2026-02-25 through 2026-05-26 UTC — exactly 90 days, half-open. Swap in your own dates. &lt;code&gt;limit=100&lt;/code&gt; is the page size; walk the remaining pages with the follow-up cursor automatically, as documented under &lt;a href="https://docs.stripe.com/api/pagination/auto" rel="noopener noreferrer"&gt;auto-pagination&lt;/a&gt;. In TypeScript with the official SDK, the paginator does that walking for you, and expanding the parent charge inline saves one request per refund:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt; &lt;span class="o"&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="nx"&gt;key&lt;/span&gt; &lt;span class="o"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRIPE_SECRET_KEY is not set&lt;/span&gt;&lt;span class="dl"&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;refund_id&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;charge_id&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;charge_amount&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;amount_refunded&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;currency&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;transfer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="nl"&gt;application_fee_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&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;for&lt;/span&gt; &lt;span class="k"&gt;await &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;refund&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;expand&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="s2"&gt;data.charge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;rows&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;refund_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;charge_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;charge&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="na"&gt;amount_refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&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="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transfer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;application_fee_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; refunds pulled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each refund reaches its parents through the expanded charge: the charge carries &lt;code&gt;amount&lt;/code&gt;, the &lt;code&gt;transfer&lt;/code&gt; ID on destination charges, and the &lt;code&gt;application_fee&lt;/code&gt; ID wherever fees were collected. On a direct-charge fleet, none of those IDs sit on the platform side — the objects live on each connected account, so query account by account by passing the account in request options:&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;const&lt;/span&gt; &lt;span class="nx"&gt;perAccount&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripeAccount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;acct_PLACEHOLDER&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;Loop that over your connected accounts and merge the rows; the rest of the audit is identical from there. Key the merge on charge ID plus refund ID, since both survive the trip across account boundaries unchanged.&lt;/p&gt;

&lt;p&gt;With rows in hand, enrich each one: fetch the transfer with its reversals expanded, fetch the application fee with its refunds expanded, and compute the two scores the next sections derive:&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;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;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee_id&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="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;transfer&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expand&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="s2"&gt;reversals&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;reversalsSum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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="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="o"&gt;=&amp;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;r&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedReversal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;transfer&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fee&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;applicationFees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expand&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="s2"&gt;refunds&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;feeRefundedSum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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="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="o"&gt;=&amp;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;r&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedFeeRefund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;fee&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refund_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedReversal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;reversalsSum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedFeeRefund&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;feeRefundedSum&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 same logic translates directly to other languages; if your team maintains Python tooling, the &lt;a href="https://feeguard.dev/integrations/python-reconciliation-scripts" rel="noopener noreferrer"&gt;Python reconciliation scripts&lt;/a&gt; page shows the equivalent requests end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transform: build the sheet
&lt;/h2&gt;

&lt;p&gt;One row per refund. The input columns come straight off the objects; the table maps each column to its source.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Pulled from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;refund_id&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;, &lt;code&gt;refund_created&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;charge_id&lt;/code&gt;, &lt;code&gt;charge_created&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Parent charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;charge_amount_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;charge.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;amount_refunded_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;refund.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transfer_amount_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transfer.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reversals_sum_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sum of the transfer's reversal amounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_fee_amount_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application_fee.amount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fee_refunded_sum_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sum of the application fee's refund amounts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four more columns are computed, not fetched; the table defines each rule, and the next section grounds the two that matter.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Computed column&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expected_reversal_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;round(amount_refunded_cents / charge_amount_cents * transfer_amount_cents)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing_transfer_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;expected_reversal_cents - reversals_sum_cents&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expected_fee_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;round(amount_refunded_cents / charge_amount_cents * app_fee_amount_cents)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing_fee_cents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;expected_fee_cents - fee_refunded_sum_cents&lt;/code&gt;, scored only where refund policy returns fees&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three housekeeping rules keep the sheet honest. Keep one sheet per currency and never sum cents across currencies. Store every amount as an integer in the smallest currency unit and convert to dollars only when printing. And format ID columns as text before pasting, because spreadsheets quietly mangle long alphanumeric tokens into something that no longer joins. Charges refunded in several installments simply occupy several rows; the formulas score each independently, and the cumulative check in the next section catches any drift between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two formulas
&lt;/h2&gt;

&lt;p&gt;Everything reduces to proportionality. Write the rules once, in cents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;expected_reversal = round((amount_refunded / charge_amount) * transfer_amount)
missing_transfer  = expected_reversal - reversals_sum
expected_fee      = round((amount_refunded / charge_amount) * app_fee_amount)
missing_fee       = expected_fee - fee_refunded_sum     (scored only if policy returns the fee)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proportionality is not a modeling choice — it mirrors Stripe's own behavior. Full refunds of destination charges reverse the entire transfer when &lt;code&gt;reverse_transfer=true&lt;/code&gt;, and partial refunds reverse a proportional amount (&lt;a href="https://docs.stripe.com/connect/destination-charges" rel="noopener noreferrer"&gt;destination charges&lt;/a&gt;). The same parameter is documented the same way at the API level: the transfer is reversed proportionally to the amount being refunded (&lt;a href="https://docs.stripe.com/api/refunds/create" rel="noopener noreferrer"&gt;refunds create&lt;/a&gt;). Application fees behave symmetrically: refunded in full on a full refund, proportionally on a partial one, when &lt;code&gt;refund_application_fee&lt;/code&gt; is set.&lt;/p&gt;

&lt;p&gt;Separate charges and transfers break the symmetry, and the formulas expose it: refunding the charge has no impact on any associated transfers, so &lt;code&gt;reversals_sum_cents&lt;/code&gt; stays zero no matter how diligent you were at refund time (&lt;a href="https://docs.stripe.com/connect/separate-charges-and-transfers" rel="noopener noreferrer"&gt;separate charges and transfers&lt;/a&gt;). Those rows score against whatever reversal you performed manually — often none.&lt;/p&gt;

&lt;p&gt;One caveat on rounding. When several partial refunds hit one charge, each row rounds independently, and independent rounding can drift a cent or two from the cumulative truth. Score per charge, cumulatively, before believing any single row: if the sum of &lt;code&gt;amount_refunded&lt;/code&gt; equals the charge amount, the expected cumulative reversal is exactly the whole transfer; between boundaries, accept dust of a cent or less per charge and investigate anything larger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked rows
&lt;/h2&gt;

&lt;p&gt;Three synthetic rows exercise every branch of the sheet. Assumptions, labeled as assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US platform, USD, Stripe's standard US card pricing of 2.9% + $0.30 (&lt;a href="https://stripe.com/pricing" rel="noopener noreferrer"&gt;published pricing&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Charge of $100.00 = 10000¢, application fee $10.00 = 1000¢, Stripe processing fee $3.20 = 320¢.&lt;/li&gt;
&lt;li&gt;Destination-charge transfer $90.00 = 9000¢; separate-charge transfer $70.00 = 7000¢.&lt;/li&gt;
&lt;li&gt;Row B is a 40% partial refund issued with both &lt;code&gt;reverse_transfer=true&lt;/code&gt; and &lt;code&gt;refund_application_fee=true&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All amounts in cents. The table shows inputs left of &lt;code&gt;expected_reversal&lt;/code&gt; and scores right of it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;charge_amount&lt;/th&gt;
&lt;th&gt;amount_refunded&lt;/th&gt;
&lt;th&gt;transfer_amount&lt;/th&gt;
&lt;th&gt;Σ reversals&lt;/th&gt;
&lt;th&gt;Σ fee refunds&lt;/th&gt;
&lt;th&gt;expected_reversal&lt;/th&gt;
&lt;th&gt;missing_transfer&lt;/th&gt;
&lt;th&gt;missing_fee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Destination, full refund, no flags&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Destination, 40% refund, both flags&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;4000&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;3600&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;3600&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Separate, full refund&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SUM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The arithmetic, line by line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Row A: &lt;code&gt;round(10000 / 10000 * 9000) = 9000&lt;/code&gt;, so missing = 9000 − 0 = 9000, which is &lt;strong&gt;$90.00&lt;/strong&gt; of transfer that stayed with the seller. The fee: &lt;code&gt;round(1.0 * 1000) = 1000&lt;/code&gt; against 0 refunded, so &lt;strong&gt;$10.00&lt;/strong&gt; kept — the default on destination charges unless the platform acts.&lt;/li&gt;
&lt;li&gt;Row B: &lt;code&gt;round(4000 / 10000 * 9000) = round(3600) = 3600&lt;/code&gt;, so missing = 3600 − 3600 = &lt;strong&gt;0&lt;/strong&gt;. The fee: &lt;code&gt;round(4000 / 10000 * 1000) = 400&lt;/code&gt; against 400 refunded, also &lt;strong&gt;0&lt;/strong&gt;. A correctly flagged partial refund scores clean.&lt;/li&gt;
&lt;li&gt;Row C: &lt;code&gt;round(10000 / 10000 * 7000) = 7000&lt;/code&gt;, so missing = 7000 − 0 = &lt;strong&gt;$70.00&lt;/strong&gt; — the full transfer, outstanding until someone reverses it manually, which itself succeeds only if the seller's available balance covers it (&lt;a href="https://feeguard.dev/recover/separate-charges-transfers-recovery" rel="noopener noreferrer"&gt;separate charges and transfers&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Totals: 9000 + 0 + 7000 = 16000¢ = &lt;strong&gt;$160.00&lt;/strong&gt; of unreversed transfer value, plus 1000¢ = &lt;strong&gt;$10.00&lt;/strong&gt; of retained fee, across three toy refunds. On real data these columns are sums, and the SUM row is the number that goes in front of decision makers. Keep the &lt;a href="https://feeguard.dev/calculator/checklist-refund-path-audit-download" rel="noopener noreferrer"&gt;refund-path audit checklist&lt;/a&gt; beside the sheet so each row's path classification stays consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The disputes pass
&lt;/h2&gt;

&lt;p&gt;Refunds are half the exposure. Lost disputes debit the platform on destination and separate charges — disputed amount plus dispute fee — and recovering from the seller is a manual transfer reversal, exactly like a refund that was issued without its flags (&lt;a href="https://docs.stripe.com/connect/disputes" rel="noopener noreferrer"&gt;Connect disputes&lt;/a&gt;). So the audit repeats itself over disputes closed as lost inside the window:&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;for&lt;/span&gt; &lt;span class="k"&gt;await &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;dispute&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disputes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;windowStart&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;expand&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="s2"&gt;data.charge&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="s2"&gt;data.charge.transfer&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dispute&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="s2"&gt;lost&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;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dispute&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="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://feeguard.dev/docs/charge.dispute.closed" rel="noopener noreferrer"&gt;charge.dispute.closed&lt;/a&gt; explainer covers the event side if you would rather catch these as they happen. For each lost dispute, ask the sheet the same question: was the transfer reversed around the loss?&lt;/p&gt;

&lt;p&gt;One worked row, assumptions labeled: destination charge of $100.00, transfer $90.00, dispute fee $15.00 at standard US pricing (&lt;a href="https://stripe.com/pricing" rel="noopener noreferrer"&gt;published pricing&lt;/a&gt;), no reversal ever made.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dispute&lt;/th&gt;
&lt;th&gt;disputed&lt;/th&gt;
&lt;th&gt;fee&lt;/th&gt;
&lt;th&gt;transfer_amount&lt;/th&gt;
&lt;th&gt;Σ reversals&lt;/th&gt;
&lt;th&gt;uncovered_seller_side&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dp_1&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Arithmetic: uncovered = 9000 − 0 = 9000, so &lt;strong&gt;$90.00&lt;/strong&gt; could still be reclaimed from the seller, while the $15.00 fee has no reversal to ride on and is gone regardless.&lt;/p&gt;

&lt;p&gt;Fold the pass into the total: 16000 + 9000 = 25000¢ of transfer-class gaps, plus the 1000¢ fee-class gap — &lt;strong&gt;$260.00&lt;/strong&gt; identified across five synthetic rows, every cent of it traced to a specific object ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you will find
&lt;/h2&gt;

&lt;p&gt;Expect shape, not scatter. The findings below are qualitative patterns this audit reliably surfaces; your sheet supplies the magnitudes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-reversal clusters concentrate.&lt;/strong&gt; Sort &lt;code&gt;missing_transfer_cents&lt;/code&gt; by the code path or dashboard origin that issued each refund and specific branches dominate — the places where refund calls went out without &lt;code&gt;reverse_transfer&lt;/code&gt;, or fee refunds were never chained. Defaults leak where they were coded in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age destroys recoverability.&lt;/strong&gt; Recent gaps can often still be reclaimed. Old ones frequently cannot, in place: the seller's balance was paid out long ago, so the money exists only as a claim, not a balance. Those rows migrate from "reverse it" to "net it or negotiate it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rounding dust accumulates.&lt;/strong&gt; Rows scoring ±1¢ from multi-partial charges are noise; let them cancel rather than chasing them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending stragglers linger.&lt;/strong&gt; Refunds sitting in &lt;code&gt;pending&lt;/code&gt; on underfunded connected accounts look like leaks in the raw data but have simply not executed yet; park them on a separate tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate-charge orphans surface.&lt;/strong&gt; Transfers nobody associated with the refunds they survived, because nothing in the object graph connects them — only your records do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distribution is the diagnostic. Uniform spread suggests nothing actionable; concentration points at a fixable call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one pass is not enough
&lt;/h2&gt;

&lt;p&gt;A snapshot ages instantly. Tomorrow's refunds repeat today's pattern, so a quarterly habit means quarters of accumulation between passes, and the oldest rows rot from "recoverable" to "write-off" while they sit. If the SUM row justifies acting at all, it eventually justifies running the identical queries continuously — the two formulas do not change week to week; only the data does. Remediation of what you find has its own procedures, from the &lt;a href="https://feeguard.dev/recover/bulk-reversal-of-historical-findings" rel="noopener noreferrer"&gt;bulk reversal of historical findings&lt;/a&gt; playbook onward, and continuous versions of this same arithmetic are precisely what ongoing monitoring automates, FeeGuard included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does this audit need write access to my Stripe account?
&lt;/h3&gt;

&lt;p&gt;No. Restricted keys scope access per resource with read or write chosen separately, so read-only over refunds, charges, transfers, application fees, and disputes covers the entire procedure (&lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;keys&lt;/a&gt;). The key never moves money, and it should still live in an environment variable rather than in the script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I do all of this from Dashboard CSV exports?
&lt;/h3&gt;

&lt;p&gt;Partially. The payments export gives you refunds and their charges, but reversals and application-fee refunds live in separate exports, and joining them means manual key matching in a spreadsheet. It works; it is slower and frailer. The &lt;a href="https://feeguard.dev/recover/recover-from-excel-export" rel="noopener noreferrer"&gt;Excel-export recovery workflow&lt;/a&gt; walks the export-based variant if you cannot use the API.&lt;/p&gt;

&lt;h3&gt;
  
  
  We settle in multiple currencies — what changes?
&lt;/h3&gt;

&lt;p&gt;Only discipline, not method. Run one sheet per currency, convert nothing until the final report, and remember that in zero-decimal currencies like JPY the integer amounts are already the major unit, so "cents" columns become "yen" columns (&lt;a href="https://docs.stripe.com/currencies" rel="noopener noreferrer"&gt;supported currencies&lt;/a&gt;). Never sum a USD column onto a EUR column.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about refunds still showing as &lt;code&gt;pending&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Score them, but on their own tab. A pending refund has not moved money yet — on direct charges it waits for the connected account's balance to fund it, then processes automatically (&lt;a href="https://docs.stripe.com/connect/charges" rel="noopener noreferrer"&gt;connect charges&lt;/a&gt;). Recheck them before you publish totals, because some will resolve themselves and some will reveal a seller whose balance never recovers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the 90-day audit
&lt;/h2&gt;

&lt;p&gt;Nothing above requires anything but a spreadsheet and an afternoon, and rerunning it weekly is exactly as tedious as it sounds. FeeGuard exists because this arithmetic runs silently on every refund your platform issues, and the running total almost never appears anywhere. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreturned application fee, and uncovered dispute loss with the amounts attached. You get the answer first; ongoing monitoring stays optional afterward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://feeguard.dev/audit" rel="noopener noreferrer"&gt;Run the free 90-day audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>audit</category>
      <category>90</category>
      <category>days</category>
    </item>
    <item>
      <title>Build your own fee-leakage detection with the Stripe API</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:30:15 +0000</pubDate>
      <link>https://dev.to/veristria/build-your-own-fee-leakage-detection-with-the-stripe-api-48ch</link>
      <guid>https://dev.to/veristria/build-your-own-fee-leakage-detection-with-the-stripe-api-48ch</guid>
      <description>&lt;h1&gt;
  
  
  Build your own fee-leakage detection with the Stripe API
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Build your own fee-leakage detection with the Stripe API
&lt;/h1&gt;

&lt;p&gt;You can detect Connect fee leakage yourself: list refunds and disputes, join them to charges, transfers, reversals, and application fees, apply two expectation formulas, and alert on the difference. This piece gives platform engineers the architecture, the exact API calls, and working code for continuous detection you own end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture in one diagram
&lt;/h2&gt;

&lt;p&gt;Five components, one data flow, no money movement anywhere in the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lister jobs ──▶ normalizer ──▶ expectation engine ──▶ findings store ──▶ alerter
     │             │                │                    │               │
     └─────────────┴───── read-only restricted key throughout ─────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lister jobs pull new refunds and disputes over the API since the last cursor. The normalizer flattens them into rows carrying everything the math needs: parent charge, transfer amount, reversals already made, application fee and its refunded portion. The expectation engine applies two formulas — proportional reversal, proportional fee refund — and emits a finding whenever actual falls short of expected. The findings store persists diffs with identifiers and ages. The alerter turns stored findings into human-visible output.&lt;/p&gt;

&lt;p&gt;Every component reads through the same restricted key. Stripe supports restricted API keys scoped per resource with read or write access (&lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;API keys&lt;/a&gt;), and an auditor process has no business holding write access at all — more on that next. If you eventually compare this stack against alternatives, the honest framing lives at &lt;a href="https://feeguard.dev/vs/homegrown-scripts" rel="noopener noreferrer"&gt;homegrown scripts versus managed detection&lt;/a&gt;; here we build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping credentials safely
&lt;/h2&gt;

&lt;p&gt;Create a restricted key in the Dashboard and grant read access to exactly six resources: Charges, Refunds, Transfers, Application Fees, Disputes, and Balance. Nothing else, and write access nowhere.&lt;/p&gt;

&lt;p&gt;The reasoning is mechanical. This system's entire job is noticing that money went somewhere it should not have; a key that cannot move money cannot make any of its own failure modes into incidents. A leaked secret key drains balances; a leaked restricted read-only key leaks data — serious, but bounded, and revocable in one click. Auditors and security reviewers also get a cleaner story: the credential proves the pipeline is incapable of self-help, so every recovery decision necessarily routes through humans. Wire it from the environment, never from source:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_RESTRICTED_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pulling the corpus
&lt;/h2&gt;

&lt;p&gt;The corpus starts from refunds, because every leak scenario begins with one. The snippet below autopaginates refunds created in a window, expands each parent charge inline, then fetches the three quantities the math depends on: the destination transfer's amount, the sum of reversals already made against it, and the application fee with its refunded portion.&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Refund&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transferAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nl"&gt;reversedCents&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;feeAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nl"&gt;feeRefundedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pullRefundRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sinceUnix&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CorpusRow&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;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&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;refunds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sinceUnix&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;expand&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="s2"&gt;data.charge&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;for&lt;/span&gt; &lt;span class="k"&gt;await &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;refund&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rows&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&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;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&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;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Refund&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CorpusRow&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;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Charge&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;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transferAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reversedCents&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="na"&gt;feeAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;feeRefundedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transferId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;transferId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listReversals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transfer&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="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversedCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;rev&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;feeId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_fee&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;feeId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fee&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;applicationFees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fee&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="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeRefundedCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&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;row&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;Three details worth internalizing. First, the &lt;code&gt;expand&lt;/code&gt; parameter saves a request per row; without it, &lt;code&gt;refund.charge&lt;/code&gt; is just an ID. Second, reversals are summed rather than assumed singular, because a transfer may carry several partial reversals from earlier refunds. Third, &lt;code&gt;amount_refunded&lt;/code&gt; on the ApplicationFee object already aggregates any separately issued fee refunds, so the fee side costs one retrieval, not a listing loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct-charge fleets.&lt;/strong&gt; On direct charges, refunds live on the connected account, not your platform — the objects are created under the connected account's context (&lt;a href="https://docs.stripe.com/connect/direct-charges" rel="noopener noreferrer"&gt;direct charges&lt;/a&gt;), so the platform-side list above never sees them. Iterate known account IDs and pass each through the header-scoped call:&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;pullForAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&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;sinceUnix&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CorpusRow&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;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&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;refunds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sinceUnix&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripeAccount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;refund&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rows&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&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;rows&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;Keep the account-ID inventory in your own database, refreshed however you onboard and offboard sellers. Disputes join the corpus the same way — &lt;code&gt;stripe.disputes.list&lt;/code&gt; with a &lt;code&gt;created&lt;/code&gt; window, expanded charges included — feeding the covered-loss check described later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expectation functions
&lt;/h2&gt;

&lt;p&gt;Two formulas carry the whole method. Both take integer cents and return integer cents; both assume nothing beyond the objects you already hold.&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;expectedReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;refundedCents&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;chargeCents&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;transferCents&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="kr"&gt;number&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;chargeCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;transferCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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="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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;refundedCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;chargeCents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;transferCents&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;expectedFeeRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;refundedCents&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;chargeCents&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;feeCents&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="kr"&gt;number&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;chargeCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;feeCents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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="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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;refundedCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;chargeCents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;feeCents&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 proportional-reversal shape is not arbitrary: it mirrors what Stripe itself does when &lt;code&gt;reverse_transfer=true&lt;/code&gt; rides on a partial refund — the transfer is reversed proportionally to the amount being refunded (&lt;a href="https://docs.stripe.com/api/refunds/create" rel="noopener noreferrer"&gt;Refunds API&lt;/a&gt;). The fee analog follows identically from full-with-full, partial-with-proportional semantics.&lt;/p&gt;

&lt;p&gt;The critical discipline is what &lt;code&gt;refundedCents&lt;/code&gt; means: &lt;strong&gt;the charge-level cumulative&lt;/strong&gt;, not each refund's individual amount. Compute it from &lt;code&gt;charge.amount_refunded&lt;/code&gt;, which the Charge object maintains for you. Aggregating this way defeats rounding drift. Watch the difference on a small case: charge 300¢, transfer 100¢, three sequential refunds of 100¢ each. Per-refund expectations round to 33¢ apiece, and 3 × 33 = 99¢ — a phantom 1¢ shortfall manufactured entirely by intermediate rounding. Charge-level: round((300 ÷ 300) × 100) = 100¢, compared once against the reversals actually on file. No phantom. The trade-off is philosophical and worth stating plainly: the cumulative model answers "is money missing right now?" rather than "which specific refund underpaid?" For leakage detection, the first question is the one with financial consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning diffs into findings
&lt;/h2&gt;

&lt;p&gt;Group corpus rows by charge, evaluate both expectations once per group, and subtract what actually happened:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;charge_id&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;missing_transfer_cents&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;missing_fee_cents&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;currency&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;age_days&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;function&lt;/span&gt; &lt;span class="nf"&gt;buildFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;corpus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CorpusRow&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;nowUnix&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;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;byCharge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;CorpusRow&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="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;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;corpus&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;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byCharge&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="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;bucket&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="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;byCharge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bucket&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="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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;byCharge&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refundedSoFar&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;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_refunded&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;missingTransfer&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;missingFee&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferAmount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;expectedReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refundedSoFar&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;charge&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="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferAmount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;missingTransfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&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;reversedCents&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeAmount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&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="k"&gt;typeof&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;feeRefundedCents&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&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;expectedFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;expectedFeeRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refundedSoFar&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;charge&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="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feeAmount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;missingFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedFee&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;feeRefundedCents&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="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;missingTransfer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;missingFee&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="k"&gt;continue&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;charge_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;missing_transfer_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;missingTransfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;missing_fee_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;missingFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;currency&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;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;age_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;nowUnix&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;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400&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;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;Run it against a corpus containing the canonical scenarios — $100.00 charge, $10.00 application fee, $90.00 transfer, standard US card pricing — and the store fills with records shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"charge_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch_ExampleFullDefault"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_transfer_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_fee_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"charge_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch_ExamplePartialFlag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_transfer_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_fee_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"charge_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch_ExampleFeeOnlyFlag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_transfer_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_fee_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"age_days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the rows: the first is a full default refund — transfer untouched ($90.00 short), fee kept ($10.00 short). The second is a $40.00 partial refund issued with &lt;code&gt;reverse_transfer=true&lt;/code&gt;; the proportional reversal landed (expected round((4000 ÷ 10000) × 9000) = 3600¢, actual 3600¢) but the proportional fee refund of 400¢ did not. The third had &lt;code&gt;refund_application_fee=true&lt;/code&gt; alone: fee squared away, $90.00 of seller-held funds left unclaimed. Every row carries the evidence trail implicitly — charge ID plus amounts — so any human can pull the underlying objects and verify the arithmetic by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduling, cursors, idempotency
&lt;/h2&gt;

&lt;p&gt;Detection is a loop, and loops need memory. Persist the last successful scan boundary and resume from it:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&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;CURSOR_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./refund-cursor.json&lt;/span&gt;&lt;span class="dl"&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;DAY_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86400&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;loadCursor&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="k"&gt;try&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CURSOR_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;created_gt&lt;/span&gt; &lt;span class="k"&gt;as&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;DAY_SECONDS&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unix&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="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CURSOR_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;created_gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unix&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&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;runScheduledScan&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&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;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadCursor&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;rows&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;pullRefundRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildFindings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&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;storeFindings&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;saveCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&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 daily cron suits most platforms: refund-driven leaks do not decay faster than daily attention, and the 90-day lookback on first run — the &lt;code&gt;catch&lt;/code&gt; branch seeds the cursor ninety days back — doubles as your initial historical sweep. Findings from that sweep will be old; route them to a batch review rather than paging anyone. Bulk-triaging aged findings is its own discipline (&lt;a href="https://feeguard.dev/recover/bulk-reversal-of-historical-findings" rel="noopener noreferrer"&gt;historical findings playbook&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;One reservation: &lt;code&gt;Idempotency-Key&lt;/code&gt; headers belong to the &lt;em&gt;action&lt;/em&gt; phase, not this one. Detection writes nothing to Stripe, so it needs no idempotency keys. The moment any component graduates to taking actions — creating a reversal, refunding an application fee — every POST carries a deterministic key, because Stripe honors keys for 24 hours to make retries safe (&lt;a href="https://docs.stripe.com/api/idempotency" rel="noopener noreferrer"&gt;idempotent requests&lt;/a&gt;):&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;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amount&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="nx"&gt;missing_transfer_cents&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`reverse-&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="nx"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policyVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never act automatically on first detection. A finding is a hypothesis with numbers attached; confirmation, context (policy version, goodwill overrides, netting already in flight), and approval come before any money moves. The design patterns for safe reversal execution are catalogued separately (&lt;a href="https://feeguard.dev/recover/idempotency-for-reversals" rel="noopener noreferrer"&gt;idempotency for reversals&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The event-driven variant
&lt;/h2&gt;

&lt;p&gt;Batch scans bound staleness at the cron interval. If you need tighter latency, subscribe to webhook events and run the identical expectation logic incrementally. The relevant set covers the money movements themselves: &lt;code&gt;refund.created&lt;/code&gt;, &lt;code&gt;refund.updated&lt;/code&gt;, &lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;application_fee.created&lt;/code&gt;, &lt;code&gt;application_fee.refunded&lt;/code&gt;, &lt;code&gt;transfer.created&lt;/code&gt;, &lt;code&gt;transfer.reversed&lt;/code&gt;, plus the dispute events (&lt;code&gt;charge.dispute.created&lt;/code&gt;, &lt;code&gt;charge.dispute.updated&lt;/code&gt;, &lt;code&gt;charge.dispute.closed&lt;/code&gt;). Verify signatures, respond quickly, process asynchronously — delivery is at-least-once with retries up to roughly three days, so handlers must be idempotent (&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;webhooks&lt;/a&gt;).&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;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&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;endpointSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt; &lt;span class="k"&gt;as&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&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="s2"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&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;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;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&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;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpointSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="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;400&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="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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refund.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge.refunded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application_fee.refunded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transfer.reversed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge.dispute.closed&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;void&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;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;span class="p"&gt;);&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;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4242&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&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;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put a queue between receiver and worker even at modest volume: the receiver's only jobs are signature verification and fast 2xx responses, while the worker fetches the affected refund, normalizes it, runs the same &lt;code&gt;buildFindings&lt;/code&gt; evaluation for that single charge, and updates the findings store. Because both paths share the expectation engine, batch and event results agree by construction — disagreements become your best regression tests. The wiring patterns for Express receivers and queued workers are documented step-by-step in the &lt;a href="https://feeguard.dev/integrations/node-express-refund-handling" rel="noopener noreferrer"&gt;Node refund-handling guide&lt;/a&gt; and the &lt;a href="https://feeguard.dev/integrations/queue-based-webhook-processing" rel="noopener noreferrer"&gt;queue-based processing guide&lt;/a&gt;; the general trade space between these approaches is mapped at &lt;a href="https://feeguard.dev/vs/building-on-webhooks-yourself" rel="noopener noreferrer"&gt;building on webhooks yourself&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance reality
&lt;/h2&gt;

&lt;p&gt;The skeleton above works. Keeping it working is the honest part of this piece, because a homegrown detector inherits maintenance obligations that never fully retire:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema and API drift.&lt;/strong&gt; Object fields, pagination behavior, and SDK typings move with API versions. Every Stripe upgrade warrants a re-read of the release notes against your normalizer, because your expectations silently encode field assumptions like &lt;code&gt;amount_refunded&lt;/code&gt; staying cumulative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-currency conversions.&lt;/strong&gt; Cross-currency refunds convert at the live rate on refund day regardless of any quote locked at payment time, and Stripe does not return the original transaction's FX fee (&lt;a href="https://docs.stripe.com/connect/currencies/fx-quotes-api" rel="noopener noreferrer"&gt;FX on refunds&lt;/a&gt;). Your expectation math stays in the charge currency, but platform-level loss reporting needs conversion handling, and FX slippage becomes its own detection problem beside the transfer math. Zero-decimal currencies such as JPY store amounts in the unit itself — the formulas survive unchanged, but every display threshold and alert constant needs currency-awareness (&lt;a href="https://docs.stripe.com/currencies#zero-decimal" rel="noopener noreferrer"&gt;currencies&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source_transaction timing.&lt;/strong&gt; Transfers created with &lt;code&gt;source_transaction&lt;/code&gt; attach to a charge's availability schedule rather than paying out immediately. The amounts remain valid inputs to the formulas, but "the transfer exists" and "the funds moved" stop being simultaneous, and reconciliation windows must tolerate the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial-refund sequences.&lt;/strong&gt; Mixed flag choices across multiple refunds on one charge are the common case, not the edge. The cumulative model handles state correctly, but per-event attribution — who under-reversed which time — requires replaying the sequence in order, which is extra machinery you will eventually want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pending and failed refunds.&lt;/strong&gt; A refund debited against an insufficient balance sits in &lt;code&gt;pending&lt;/code&gt; and may lack a balance transaction until funded; failed refunds return funds within up to roughly thirty days and carry &lt;code&gt;failure_balance_transaction&lt;/code&gt; and &lt;code&gt;failure_reason&lt;/code&gt; (&lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;refunds&lt;/a&gt;). Scan loops must skip-and-recheck rather than treat either state as final.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct-charge fleets.&lt;/strong&gt; Account inventories churn, closed accounts linger, rate limits arrive per-account, and a fleet spanning hundreds of accounts turns yesterday's simple loop into a scheduling problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes as their own lane.&lt;/strong&gt; On destination and separate charges the disputed amount and the dispute fee debit the platform balance, and recovering from a seller is manual (&lt;a href="https://docs.stripe.com/connect/disputes" rel="noopener noreferrer"&gt;disputes on Connect&lt;/a&gt;). Extending the engine to covered-loss checks means modeling dispute outcomes, win rates, and re-transfer decisions — genuinely worth doing, genuinely separate work.&lt;/p&gt;

&lt;p&gt;None of this is disqualifying; all of it is permanent. That is the actual decision in front of you: the detection arithmetic fits in a file, but the upkeep — version drift, currency edges, state machines, fleet mechanics — is an ongoing product you would now operate. Some platforms should operate it; the exercise above is the honest way to find out whether yours is one of them, because building even the skeleton surfaces every question a maintained system must answer forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Math.round specifically?
&lt;/h3&gt;

&lt;p&gt;Because a convention beats a debate. Rounding half-up matches the convention used throughout Stripe's proportional behavior as commonly modeled, and — more importantly — holding one convention permanently matters more than which one you pick. Switching conventions mid-stream manufactures phantom findings out of thin air, so write the choice down next to the policy version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do zero-decimal currencies break the formulas?
&lt;/h3&gt;

&lt;p&gt;No. In zero-decimal currencies the amount is already expressed in the unit itself, so a JPY charge and its refund use identical units and the ratios come out clean. What breaks naively is presentation and thresholds: an alert constant tuned as "$10" reads absurdly in yen unless your code converts display units per currency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not compute expectations inside Sigma?
&lt;/h3&gt;

&lt;p&gt;Sigma queries recorded data read-only, and expectations are not recorded anywhere in the schema — there is no column holding what should have been reversed. You can export raw components from Sigma and feed them into these same formulas, which is a legitimate hybrid, but the arithmetic always happens outside Stripe.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should the scanner treat pending refunds?
&lt;/h3&gt;

&lt;p&gt;Skip them, deliberately. Track their status and revisit on the next cycle; a pending refund typically lacks the balance transaction your normalizer joins on, and treating absence-of-evidence as a finding floods the store with noise during balance shortages. Only finalized refunds produce stable expectations.&lt;/p&gt;

&lt;h3&gt;
  
  
  When exactly do I add Idempotency-Key headers?
&lt;/h3&gt;

&lt;p&gt;Only when the system starts taking actions. Detection POSTs nothing, so it needs no keys. The first createReversal or fee-refund call should carry a deterministic key built from the charge ID and policy version, keeping 24-hour retries safe — and that transition is precisely where automatic action should start requiring human approval first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the free 90-day audit
&lt;/h2&gt;

&lt;p&gt;If you would rather see the findings before committing to operating the machinery, FeeGuard runs this exact expectation arithmetic — proportional reversals, application-fee refunds, dispute losses, FX slippage — over your last 90 days of Connect activity through a restricted, read-only API key, and reports every occurrence with the underlying Stripe evidence attached. You get the answer first; monitoring is optional afterward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://feeguard.dev/audit" rel="noopener noreferrer"&gt;Run the free 90-day audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>feeguard</category>
      <category>build</category>
      <category>your</category>
      <category>own</category>
    </item>
  </channel>
</rss>
