Skip to content

Commit 0b5111d

Browse files
simonhampclaude
andcommitted
Fix plugin payout currency mismatch for non-USD developers
Use the source charge's currency for Stripe transfers instead of the developer's payout currency. Stripe requires the transfer currency to match the source_transaction's charge currency; FX to the connected account is handled on the destination side. Previously every non-USD developer's payouts failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1ac381d commit 0b5111d

2 files changed

Lines changed: 157 additions & 7 deletions

File tree

app/Services/StripeConnectService.php

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,18 @@ public function processTransfer(PluginPayout $payout): bool
106106
return false;
107107
}
108108

109-
// Get the charge ID from the payment intent to use as source_transaction
110-
// This ensures the transfer uses funds from this specific charge and waits for them to be available
111-
$chargeId = $this->getChargeIdFromPayout($payout);
109+
// Get the charge ID and currency from the payment intent to use as source_transaction.
110+
// Stripe requires the transfer currency to match the source charge currency; FX to the
111+
// connected account's payout currency is handled by Stripe on the destination side.
112+
$chargeDetails = $this->getChargeDetailsFromPayout($payout);
113+
$chargeId = $chargeDetails['id'] ?? null;
114+
$transferCurrency = $chargeDetails['currency']
115+
?? strtolower($developerAccount->payout_currency ?? 'usd');
112116

113117
try {
114118
$transferParams = [
115119
'amount' => $payout->developer_amount,
116-
'currency' => strtolower($developerAccount->payout_currency ?? 'usd'),
120+
'currency' => $transferCurrency,
117121
'destination' => $developerAccount->stripe_connect_account_id,
118122
'metadata' => [
119123
'payout_id' => $payout->id,
@@ -134,6 +138,7 @@ public function processTransfer(PluginPayout $payout): bool
134138
'payout_id' => $payout->id,
135139
'transfer_id' => $transfer->id,
136140
'amount' => $payout->developer_amount,
141+
'currency' => $transferCurrency,
137142
'source_transaction' => $chargeId,
138143
]);
139144

@@ -151,7 +156,10 @@ public function processTransfer(PluginPayout $payout): bool
151156
}
152157
}
153158

154-
protected function getChargeIdFromPayout(PluginPayout $payout): ?string
159+
/**
160+
* @return array{id: ?string, currency: ?string}|null
161+
*/
162+
protected function getChargeDetailsFromPayout(PluginPayout $payout): ?array
155163
{
156164
$license = $payout->pluginLicense;
157165

@@ -162,9 +170,12 @@ protected function getChargeIdFromPayout(PluginPayout $payout): ?string
162170
try {
163171
$paymentIntent = Cashier::stripe()->paymentIntents->retrieve($license->stripe_payment_intent_id);
164172

165-
return $paymentIntent->latest_charge;
173+
return [
174+
'id' => $paymentIntent->latest_charge,
175+
'currency' => $paymentIntent->currency,
176+
];
166177
} catch (\Exception $e) {
167-
Log::warning('Could not retrieve charge ID from payment intent', [
178+
Log::warning('Could not retrieve charge details from payment intent', [
168179
'payment_intent_id' => $license->stripe_payment_intent_id,
169180
'error' => $e->getMessage(),
170181
]);
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
namespace Tests\Feature\Services;
4+
5+
use App\Enums\PayoutStatus;
6+
use App\Models\DeveloperAccount;
7+
use App\Models\Plugin;
8+
use App\Models\PluginLicense;
9+
use App\Models\PluginPayout;
10+
use App\Services\StripeConnectService;
11+
use Illuminate\Foundation\Testing\RefreshDatabase;
12+
use PHPUnit\Framework\Attributes\Test;
13+
use Stripe\PaymentIntent;
14+
use Stripe\StripeClient;
15+
use Stripe\Transfer;
16+
use Tests\TestCase;
17+
18+
class StripeConnectServiceTest extends TestCase
19+
{
20+
use RefreshDatabase;
21+
22+
#[Test]
23+
public function process_transfer_uses_the_source_charge_currency_not_the_developer_payout_currency(): void
24+
{
25+
$developerAccount = DeveloperAccount::factory()->create([
26+
'payout_currency' => 'EUR',
27+
'stripe_connect_account_id' => 'acct_test_eur',
28+
]);
29+
$plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]);
30+
$license = PluginLicense::factory()->create([
31+
'plugin_id' => $plugin->id,
32+
'stripe_payment_intent_id' => 'pi_test_usd',
33+
]);
34+
35+
$payout = PluginPayout::create([
36+
'plugin_license_id' => $license->id,
37+
'developer_account_id' => $developerAccount->id,
38+
'gross_amount' => 1000,
39+
'platform_fee' => 300,
40+
'developer_amount' => 700,
41+
'status' => PayoutStatus::Pending,
42+
'eligible_for_payout_at' => now()->subDay(),
43+
]);
44+
45+
$capturedTransferParams = null;
46+
47+
$mockPaymentIntents = new class
48+
{
49+
public function retrieve(): PaymentIntent
50+
{
51+
return PaymentIntent::constructFrom([
52+
'id' => 'pi_test_usd',
53+
'currency' => 'usd',
54+
'latest_charge' => 'ch_test_usd',
55+
]);
56+
}
57+
};
58+
59+
$mockTransfers = new class($capturedTransferParams)
60+
{
61+
public function __construct(private &$capturedTransferParams) {}
62+
63+
public function create(array $params): Transfer
64+
{
65+
$this->capturedTransferParams = $params;
66+
67+
return Transfer::constructFrom(['id' => 'tr_test_123']);
68+
}
69+
};
70+
71+
$mockStripeClient = $this->createMock(StripeClient::class);
72+
$mockStripeClient->paymentIntents = $mockPaymentIntents;
73+
$mockStripeClient->transfers = $mockTransfers;
74+
75+
$this->app->bind(StripeClient::class, fn () => $mockStripeClient);
76+
77+
$result = app(StripeConnectService::class)->processTransfer($payout);
78+
79+
$this->assertTrue($result);
80+
$this->assertNotNull($capturedTransferParams);
81+
$this->assertSame('usd', $capturedTransferParams['currency']);
82+
$this->assertSame('ch_test_usd', $capturedTransferParams['source_transaction']);
83+
$this->assertSame(700, $capturedTransferParams['amount']);
84+
$this->assertSame('acct_test_eur', $capturedTransferParams['destination']);
85+
86+
$this->assertTrue($payout->fresh()->isTransferred());
87+
$this->assertSame('tr_test_123', $payout->fresh()->stripe_transfer_id);
88+
}
89+
90+
#[Test]
91+
public function process_transfer_falls_back_to_payout_currency_when_charge_lookup_fails(): void
92+
{
93+
$developerAccount = DeveloperAccount::factory()->create([
94+
'payout_currency' => 'EUR',
95+
'stripe_connect_account_id' => 'acct_test_eur',
96+
]);
97+
$plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]);
98+
$license = PluginLicense::factory()->create([
99+
'plugin_id' => $plugin->id,
100+
'stripe_payment_intent_id' => null,
101+
]);
102+
103+
$payout = PluginPayout::create([
104+
'plugin_license_id' => $license->id,
105+
'developer_account_id' => $developerAccount->id,
106+
'gross_amount' => 1000,
107+
'platform_fee' => 300,
108+
'developer_amount' => 700,
109+
'status' => PayoutStatus::Pending,
110+
'eligible_for_payout_at' => now()->subDay(),
111+
]);
112+
113+
$capturedTransferParams = null;
114+
115+
$mockTransfers = new class($capturedTransferParams)
116+
{
117+
public function __construct(private &$capturedTransferParams) {}
118+
119+
public function create(array $params): Transfer
120+
{
121+
$this->capturedTransferParams = $params;
122+
123+
return Transfer::constructFrom(['id' => 'tr_test_456']);
124+
}
125+
};
126+
127+
$mockStripeClient = $this->createMock(StripeClient::class);
128+
$mockStripeClient->transfers = $mockTransfers;
129+
130+
$this->app->bind(StripeClient::class, fn () => $mockStripeClient);
131+
132+
$result = app(StripeConnectService::class)->processTransfer($payout);
133+
134+
$this->assertTrue($result);
135+
$this->assertNotNull($capturedTransferParams);
136+
$this->assertSame('eur', $capturedTransferParams['currency']);
137+
$this->assertArrayNotHasKey('source_transaction', $capturedTransferParams);
138+
}
139+
}

0 commit comments

Comments
 (0)