Gumroad watchdog · finding · antiwork/gumroad PR #7676

Three notes on the Stripe balance top-up alert

PR #7676 Say when the Stripe balance top-up is due and what to do about it merged 2026-09-15 15:51 UTCcommit a86082c45

Found by two independent reviews and checked by a third. Nothing was run against a live system, so each note is phrased as a question to verify. The third note concerns a change made during review, and is written with that in mind.

We trained an AI agent for Gumroad. It reviews each PR merged to antiwork/gumroad with knowledge of how the codebase has evolved and which changes have caused bugs before.

1. A failed Stripe call can permanently swallow the all-clear notification

The job writes the "top-up needed" flag before it builds the message, and building the message makes a second Stripe request. If that request fails, no notification is enqueued and the flag is already false. On the retry the balance is sufficient and the previous state is gone, so the all-clear is never sent.
app/sidekiq/send_stripe_balance_check_notification_job.rb
10  def perform
11    return unless Rails.env.production?
12    return if Feature.active?(:disable_stripe_balance_check_notification)
13
14    balance_check = StripeBalanceCheckService.new
15
16    was_needed = $redis.get(RedisKey.stripe_balance_topup_needed) == "true"
17    $redis.set(RedisKey.stripe_balance_topup_needed, balance_check.topup_needed?)
18
19    if balance_check.topup_needed?
20      notify(balance_check, "red")
21    elsif was_needed
22      # Yesterday's alert asked for money; say so when it's no longer needed.
23      notify(balance_check, "green")
24    end
25  end

How to verify. Start with the flag set to true and a sufficient balance. Make the payout listing raise a connection error on the first attempt and succeed on the second. Run the job twice. No notification is sent at all.

A possible fix. Build the message first and write the flag only after the notification is enqueued, or rescue around the flag write so a failure leaves the previous state in place.

2. A Friday alert can describe next week's cycle using this week's estimate

On a Friday afternoon the next run and the last run of the cycle both point at next week, while the payout cutoff date still points at the current cycle. The amount needed is then calculated from balances up to the old cutoff, so the alert can present next week's cycle as funded using an estimate that excludes the announced cycle's balances.
app/services/stripe_balance_check_service.rb
12  PAYOUT_RUN_WDAYS = [2, 3, 4, 5].freeze
13
14  def initialize(now: Time.current)
15    @now = now.utc
16    @payout_end_date = User::PayoutSchedule.next_scheduled_payout_end_date
17    @upcoming_payouts_cents = PayoutEstimates.estimate_gumroad_held_stripe_cents(@payout_end_date)
18
19    balance = Stripe::Balance.retrieve
20    @available_cents = usd_cents(balance.available)
21    # Pending sales settle within a couple of business days -- inside the weekly payout window --
22    # so they fund the upcoming payouts. Available-only over-reports the top-up and fires false alarms.

How to verify. Freeze time on a Friday afternoon, set the previous alert state, and create an eligible seller with a large unpaid balance dated after the current cutoff and insufficient Stripe funds. The estimate for the announced cycle omits that balance.

A possible fix. Derive the estimate's cutoff from the same cycle the message announces, so the dates and the amount always describe one cycle.

3. The bank-sweep figure and the sentence around it now disagree

During review this PR changed the figure from every payout except canceled to only payouts already marked paid, and the wording became "Stripe paid ... out to Gumroad's bank in the last 24 hours". Read as settled money, the figure is right. The sentence continues "those automatic sweeps are what draw the balance down", and that half no longer holds. A payout debits the balance when it is created and reaches paid around its arrival date, so sweeps that already reduced the balance are left out of the number offered as the explanation for the drop. On a day when the only sweep is still pending, the line reads zero beside a balance that visibly fell.
app/services/stripe_balance_check_service.rb
61  # tells the reader where the money went.
62  def swept_to_bank_last_day_cents
63    @swept_to_bank_last_day_cents ||= Stripe::Payout.list(
64      created: { gte: (@now - 1.day).to_i },
65      limit: 100
66    ).auto_paging_each.sum do |payout|
67      payout.currency == Currency::USD && payout.status == "paid" ? payout.amount : 0
68    end
69  end
70
app/sidekiq/send_stripe_balance_check_notification_job.rb, the sentence that presents it
45        "Stripe paid #{formatted_dollar_amount(balance_check.swept_to_bank_last_day_cents)} out to Gumroad's bank in the last 24 hours; " \
46        "those automatic sweeps are what draw the balance down.",

Evidence in the repository. The repository's own fixture records this: a payout of 99,959,940 cents with status pending, while available drops from 202,959,940 to 103,000,000 cents in the same exchange. Gumroad's stuck-payouts job also treats a payout as still processing until its arrival date has passed.

How to verify. Add a service spec with a USD bank payout created an hour ago whose status is pending or in_transit. The swept total returns zero while the available balance already reflects it.

A possible fix. Either keep the paid-only figure and drop the causal clause, or report both numbers, settled and in flight, so the sentence and the figure agree.

These reviews improve over time

The agent learns from every merged PR and from every reply. When a finding is confirmed or fixed, that pattern is weighted up; when one is refuted, the correction is fed back and the agent stops raising it. Each review is more precise than the one before it.

If this was useful

Paying is optional. If this was useful, you can pay what you believe is fair: https://buy.stripe.com/4gMeVdfpVgl18Td4hg5AQ00. Reply on the pull request if you want to keep receiving our recommendations on every merged PR. The findings stand either way.