diff --git a/app/Services/StripeConnectService.php b/app/Services/StripeConnectService.php index 925b2ea0..8402de61 100644 --- a/app/Services/StripeConnectService.php +++ b/app/Services/StripeConnectService.php @@ -106,14 +106,18 @@ public function processTransfer(PluginPayout $payout): bool return false; } - // Get the charge ID from the payment intent to use as source_transaction - // This ensures the transfer uses funds from this specific charge and waits for them to be available - $chargeId = $this->getChargeIdFromPayout($payout); + // Get the charge ID and currency from the payment intent to use as source_transaction. + // Stripe requires the transfer currency to match the source charge currency; FX to the + // connected account's payout currency is handled by Stripe on the destination side. + $chargeDetails = $this->getChargeDetailsFromPayout($payout); + $chargeId = $chargeDetails['id'] ?? null; + $transferCurrency = $chargeDetails['currency'] + ?? strtolower($developerAccount->payout_currency ?? 'usd'); try { $transferParams = [ 'amount' => $payout->developer_amount, - 'currency' => strtolower($developerAccount->payout_currency ?? 'usd'), + 'currency' => $transferCurrency, 'destination' => $developerAccount->stripe_connect_account_id, 'metadata' => [ 'payout_id' => $payout->id, @@ -134,6 +138,7 @@ public function processTransfer(PluginPayout $payout): bool 'payout_id' => $payout->id, 'transfer_id' => $transfer->id, 'amount' => $payout->developer_amount, + 'currency' => $transferCurrency, 'source_transaction' => $chargeId, ]); @@ -151,7 +156,10 @@ public function processTransfer(PluginPayout $payout): bool } } - protected function getChargeIdFromPayout(PluginPayout $payout): ?string + /** + * @return array{id: ?string, currency: ?string}|null + */ + protected function getChargeDetailsFromPayout(PluginPayout $payout): ?array { $license = $payout->pluginLicense; @@ -162,9 +170,12 @@ protected function getChargeIdFromPayout(PluginPayout $payout): ?string try { $paymentIntent = Cashier::stripe()->paymentIntents->retrieve($license->stripe_payment_intent_id); - return $paymentIntent->latest_charge; + return [ + 'id' => $paymentIntent->latest_charge, + 'currency' => $paymentIntent->currency, + ]; } catch (\Exception $e) { - Log::warning('Could not retrieve charge ID from payment intent', [ + Log::warning('Could not retrieve charge details from payment intent', [ 'payment_intent_id' => $license->stripe_payment_intent_id, 'error' => $e->getMessage(), ]); diff --git a/tests/Feature/Services/StripeConnectServiceTest.php b/tests/Feature/Services/StripeConnectServiceTest.php new file mode 100644 index 00000000..4ae68334 --- /dev/null +++ b/tests/Feature/Services/StripeConnectServiceTest.php @@ -0,0 +1,139 @@ +create([ + 'payout_currency' => 'EUR', + 'stripe_connect_account_id' => 'acct_test_eur', + ]); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'stripe_payment_intent_id' => 'pi_test_usd', + ]); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'gross_amount' => 1000, + 'platform_fee' => 300, + 'developer_amount' => 700, + 'status' => PayoutStatus::Pending, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $capturedTransferParams = null; + + $mockPaymentIntents = new class + { + public function retrieve(): PaymentIntent + { + return PaymentIntent::constructFrom([ + 'id' => 'pi_test_usd', + 'currency' => 'usd', + 'latest_charge' => 'ch_test_usd', + ]); + } + }; + + $mockTransfers = new class($capturedTransferParams) + { + public function __construct(private &$capturedTransferParams) {} + + public function create(array $params): Transfer + { + $this->capturedTransferParams = $params; + + return Transfer::constructFrom(['id' => 'tr_test_123']); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->paymentIntents = $mockPaymentIntents; + $mockStripeClient->transfers = $mockTransfers; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + $result = app(StripeConnectService::class)->processTransfer($payout); + + $this->assertTrue($result); + $this->assertNotNull($capturedTransferParams); + $this->assertSame('usd', $capturedTransferParams['currency']); + $this->assertSame('ch_test_usd', $capturedTransferParams['source_transaction']); + $this->assertSame(700, $capturedTransferParams['amount']); + $this->assertSame('acct_test_eur', $capturedTransferParams['destination']); + + $this->assertTrue($payout->fresh()->isTransferred()); + $this->assertSame('tr_test_123', $payout->fresh()->stripe_transfer_id); + } + + #[Test] + public function process_transfer_falls_back_to_payout_currency_when_charge_lookup_fails(): void + { + $developerAccount = DeveloperAccount::factory()->create([ + 'payout_currency' => 'EUR', + 'stripe_connect_account_id' => 'acct_test_eur', + ]); + $plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]); + $license = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'stripe_payment_intent_id' => null, + ]); + + $payout = PluginPayout::create([ + 'plugin_license_id' => $license->id, + 'developer_account_id' => $developerAccount->id, + 'gross_amount' => 1000, + 'platform_fee' => 300, + 'developer_amount' => 700, + 'status' => PayoutStatus::Pending, + 'eligible_for_payout_at' => now()->subDay(), + ]); + + $capturedTransferParams = null; + + $mockTransfers = new class($capturedTransferParams) + { + public function __construct(private &$capturedTransferParams) {} + + public function create(array $params): Transfer + { + $this->capturedTransferParams = $params; + + return Transfer::constructFrom(['id' => 'tr_test_456']); + } + }; + + $mockStripeClient = $this->createMock(StripeClient::class); + $mockStripeClient->transfers = $mockTransfers; + + $this->app->bind(StripeClient::class, fn () => $mockStripeClient); + + $result = app(StripeConnectService::class)->processTransfer($payout); + + $this->assertTrue($result); + $this->assertNotNull($capturedTransferParams); + $this->assertSame('eur', $capturedTransferParams['currency']); + $this->assertArrayNotHasKey('source_transaction', $capturedTransferParams); + } +}