Skip to content

Pay Stripe-held balances in the currency they are held in (one payment per currency) - #7740

Draft
gumclaw wants to merge 2 commits into
mainfrom
gumclaw/gp2693-eur-payout-rail
Draft

gumclaw wants to merge 2 commits into
mainfrom
gumclaw/gp2693-eur-payout-rail

Conversation

@gumclaw

@gumclaw gumclaw commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What / why

Pay Stripe-held balances in their holding currency, with one Payment per connected account and currency. Previously, balances in a currency different from the account default were excluded from every payout run.

Before / after

Foreign-currency groups become payable only when Stripe reports available funds and an external account in that currency. Preparation, destination selection, and webhook reconciliation use the Payment currency; unsupported groups remain unpaid. The existing payout preview is unchanged.

Existing live evidence, collected before this revision: a EUR 4,602.07 payout was accepted without a destination override or currency conversion, with the matching Payment and balances reconciled to the cent. Stripe reported pending with arrival September 18. Account and ledger identifiers are confined to the private tracker. No production payout is being run for this revision.

Test results

Revision in progress: addressing group-failure isolation, stale single-payment test expectations, and Ruby lint. Local regression tests, mutation proofs, exact-head panel review, and CI results will be recorded here before this draft is made ready.


AI assistance: GPT-6 Astra. Prompt: “Get 7740 green and ready”; repair the reviewed payout failure modes, verify tests and mutations, and hand this high-risk change to a human without automerge.

A connected Stripe account holds a separate balance per currency, and Stripe settles a
destination charge in the currency the buyer was charged in, so an account whose own currency
is `huf` can hold real `eur` money. `is_balance_payable` required `holding_currency ==
merchant_account.currency`, so those rows were never claimed by a payout run: they sat `unpaid`
forever while the payouts page kept advertising them (gumroad-private#2693).

A balance is now payable when its own connected account can actually pay that currency — it
holds a positive Stripe balance in it and has a bank account there to receive it. Claimed
balances are grouped per (account, currency) and paid as one Payment per group, each in its own
currency, with the CURRENCY_MISMATCH guard still refusing a mixed or unpayable group. A payout
whose currency is not the seller's Gumroad bank-account currency omits `destination` so Stripe
pays the account's own bank account for that currency, and the payout webhook now matches
Gumroad-created payouts by their Payment's currency instead of the account's.
@gumclaw gumclaw self-assigned this Sep 17, 2026
@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 3/5

The PR is not safe to merge until multi-group failures can no longer leave successfully prepared payments undispatched in the processing state and the explicit comment rule is satisfied.

Findings

  1. P1 Successful payouts remain undispatched
  2. P1 Partial groups become stranded
  3. P2 Comments violate repository guidance
Fix with agent prompt
### Issue 1
app/models/scheduled_payout.rb:86-96
When one currency group fails preparation, this loop returns or raises before the dispatch loop at lines 99–121. The failed payment returns only its own balances to `unpaid`, while any successful sibling has already been marked `processing` but is never sent or enqueued. That payment then blocks later payouts until stale-payout recovery handles it.

### Issue 2
app/business/payments/payouts/payouts.rb:500-512
Each earlier group is saved and marked `processing` before the next group is prepared, but preparation is outside the save-error unwind. If a later Stripe group raises an authentication, connection, or preparation error, `filter_map` never returns the earlier payments to either caller. Those payments are not dispatched, and their balances remain `processing` until stale-payout recovery runs days later.

### Issue 3
app/business/payments/payouts/processor/stripe/stripe_payout_processor.rb:120-131
This twelve-line method comment repeats implementation details and includes issue-history narration. The repository requires comments to retain only non-obvious reasons, ordering constraints, and traps, while removing company history and line-by-line restatement and reconsidering comments longer than roughly three lines. This requirement must be satisfied before merging; the same pattern also appears around `pay_out_currencies` and `payout_groups`.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR expands Stripe payout handling from one account currency to one payment per connected-account currency, including currency-specific eligibility, preparation, dispatch, and webhook reconciliation.

  • Adds Stripe balance and external-account capability probing with a ten-minute cache.
  • Groups claimed balances by connected account and holding currency.
  • Updates scheduled and batch payout callers to process multiple payments.
  • Pays foreign-currency groups through Stripe's default destination for that currency.
  • Preserves currency validation during preparation and webhook reconciliation.
  • The multi-payment lifecycle needs correction so one failing group cannot strand successful siblings in processing.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Claim payable balances] --> B[Group by connected account and currency]
  B --> C1[Prepare currency group A]
  B --> C2[Prepare currency group B]
  C1 --> D1{Preparation succeeds?}
  C2 --> D2{Preparation succeeds?}
  D1 -->|Yes| E1[Payment marked processing]
  D2 -->|Yes| E2[Payment marked processing]
  D1 -->|No| F1[Payment failed and balances unpaid]
  D2 -->|No| F2[Payment failed and balances unpaid]
  E1 --> G[Dispatch each successful payment]
  E2 --> G
  G --> H[Stripe payout in payment currency]
  H --> I[Webhook reconciles Payment]
Loading

Reviews (1) · Last reviewed commit: "Pay Stripe-held balances in the currency..."

Comment on lines 86 to 96
@@ -92,16 +96,20 @@ def execute!
return :flagged

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Successful payouts remain undispatched

When one currency group fails preparation, this loop returns or raises before the dispatch loop at lines 99–121. The failed payment returns only its own balances to unpaid, while any successful sibling has already been marked processing but is never sent or enqueued. That payment then blocks later payouts until stale-payout recovery handles it.

Knowledge Base Used: Balances and payouts

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/models/scheduled_payout.rb
Line: 86-96

Comment:
**Successful payouts remain undispatched**

When one currency group fails preparation, this loop returns or raises before the dispatch loop at lines 99–121. The failed payment returns only its own balances to `unpaid`, while any successful sibling has already been marked `processing` but is never sent or enqueued. That payment then blocks later payouts until stale-payout recovery handles it.

**Knowledge Base Used:** [Balances and payouts](https://app.greptile.com/gumroad/-/custom-context/knowledge-base/antiwork/gumroad/-/docs/balances-and-payouts.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +500 to +512
payment_errors = if payout_processor.respond_to?(:payout_groups)
payout_processor.prepare_payment_and_set_amount(
payment, group_balances, merchant_account, payout_currency
)
else
payout_processor.prepare_payment_and_set_amount(payment, group_balances)
end
raise save_error
# The payout processor can mark the payment as failed while preparing it (for example when
# no valid merchant account exists, or a balance's holding currency does not match the payout
# destination). A failed payment cannot transition to processing, so only mark it processing
# when preparation left it in a payable state — otherwise return the failed payment along
# with the preparation errors and let the caller handle it.
payment.mark_processing! unless payment.failed?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial groups become stranded

Each earlier group is saved and marked processing before the next group is prepared, but preparation is outside the save-error unwind. If a later Stripe group raises an authentication, connection, or preparation error, filter_map never returns the earlier payments to either caller. Those payments are not dispatched, and their balances remain processing until stale-payout recovery runs days later.

Knowledge Base Used: Balances and payouts

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/business/payments/payouts/payouts.rb
Line: 500-512

Comment:
**Partial groups become stranded**

Each earlier group is saved and marked `processing` before the next group is prepared, but preparation is outside the save-error unwind. If a later Stripe group raises an authentication, connection, or preparation error, `filter_map` never returns the earlier payments to either caller. Those payments are not dispatched, and their balances remain `processing` until stale-payout recovery runs days later.

**Knowledge Base Used:** [Balances and payouts](https://app.greptile.com/gumroad/-/custom-context/knowledge-base/antiwork/gumroad/-/docs/balances-and-payouts.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +120 to +131
# Public: Whether the connected account can actually pay funds out in `currency`.
#
# A Stripe account holds a separate balance per currency and Stripe settles a destination charge
# in the currency the buyer was charged in, so an account whose own currency is `huf` can still
# hold real `eur` money (gumroad-private#2693). Payouts need both sides to line up: Stripe rejects
# a payout in a currency the account holds no funds in, and one with no bank account to receive it.
# Requiring both keeps a balance we cannot pay `unpaid` (it rolls forward, as before) instead of
# creating a payout Stripe rejects.
#
# The account's own currency is answered without a Stripe call — that's the overwhelmingly common
# path — and anything else comes from `pay_out_currencies`.
def self.pay_out_currency?(merchant_account, currency)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Comments violate repository guidance

This twelve-line method comment repeats implementation details and includes issue-history narration. The repository requires comments to retain only non-obvious reasons, ordering constraints, and traps, while removing company history and line-by-line restatement and reconsidering comments longer than roughly three lines. This requirement must be satisfied before merging; the same pattern also appears around pay_out_currencies and payout_groups.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/business/payments/payouts/processor/stripe/stripe_payout_processor.rb
Line: 120-131

Comment:
**Comments violate repository guidance**

This twelve-line method comment repeats implementation details and includes issue-history narration. The repository requires comments to retain only non-obvious reasons, ordering constraints, and traps, while removing company history and line-by-line restatement and reconsidering comments longer than roughly three lines. This requirement must be satisfied before merging; the same pattern also appears around `pay_out_currencies` and `payout_groups`.

**Context Used:** CLAUDE.md ([source](https://github.com/antiwork/gumroad/blob/main/CLAUDE.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@gumclaw

gumclaw commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Live evidence from the production account this fix targets, for the record: the EUR-held balance was released as a real Stripe payout on the connected account (EUR 4,602.07, status pending, arrival 2026-09-18) with no destination passed, and Stripe sent it to the account's own EUR bank leg while the seller's Gumroad bank account is the HUF one, so omitting the destination when the currencies differ is observed behaviour rather than an assumption. The ledger reconciles to the cent: the Payment row is currency=eur, amount_cents=460207 with the released balances attached summing to exactly 460207 holding_amount_cents. That payout carries no metadata.payment key, so the present? guard on the metadata check is load-bearing for it (without it the payout.paid event raises instead of completing the payment). Blast radius of the underlying bug (unpaid Stripe-held balances whose holding_currency differs from the account currency, all Gumroad-managed Stripe accounts, replica, 2026-09-17): 141 rows / 106 accounts, net -10,263.54 USD cents, positive side 23 rows / 12 accounts / +434.04 — pairs cad->usd 51/26, gbp->usd 34/34, aud->usd 16/16, eur->usd 13/13, huf->eur 9/4, ron->eur 3/1, usd->cad 3/1, usd->gbp 2/1, usd->eur 1/1, eur->gbp 1/1, dkk->eur 1/1, czk->eur 1/1, sek->eur 1/1, five single negative rows. CI heads-up on the current head: Lint Ruby, Test Minitest 0 and Test Relevant 0/10 are red.

perform_payment decided whether to name the seller's bank account by comparing
the payout currency with the BankAccount record's currency, but an ACH-type
record reports usd whatever country it is in, so every cad payout on a Canadian
managed account silently stopped passing destination. Compare the payout
currency with the connected account's own currency instead: a foreign-currency
group (eur held on a huf account) still omits it, which is the case this branch
needs, and every account-currency payout keeps the destination it had before.

Also fixes the rubocop offenses on this branch (Performance/SelectMap in
pay_out_currencies, Layout/FirstArrayElementIndentation in the new test).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

working Someone is actively working this — don't duplicate effort

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants