FLUX-SE / PayumStripe

Payum Stripe gateways (with SCA support)
MIT License
28 stars 15 forks source link

[SOLVED] Third party payment intents #14

Closed grandiandrea closed 2 years ago

grandiandrea commented 3 years ago

Is there a way to implement third party payments (from a customer to a stripe account), like it's done here in the stripe docs?

$payment_intent = \Stripe\PaymentIntent::create([
  'payment_method_types' => ['card'],
  'amount' => 1000,
  'currency' => 'eur',
  'application_fee_amount' => 123,
], ['stripe_account' => '{{CONNECTED_STRIPE_ACCOUNT_ID}}']);

I'm guessing it needs to be done in a similar way to the subscriptions, so by building a ConvertPaymentAction, but I'm not really sure how and if the ['stripe_account' => '{{CONNECTED_STRIPE_ACCOUNT_ID}}'] being a different parameter in the function call changes things. Also if it changes anything, I am using symfony.

Prometee commented 3 years ago

Hello @MotAtlas,

This is totally possible, you will have to extends the CaptureAction and change the creation of the capture resource. Here is an example using Stripe Checkout Session :

(I assume you are using the v1.2 version of this library )

<?php

declare(strict_types=1);

namespace App\PayumStripe\Action\StripeCheckoutSession;

use FluxSE\PayumStripe\Action\CaptureAction;
use FluxSE\PayumStripe\Request\Api\Resource\CreateSession;
use Payum\Core\Bridge\Spl\ArrayObject;
use Payum\Core\Request\Capture;
use Stripe\ApiResource;

final class CaptureThirdPartyAction extends CaptureAction
{
    protected function createCaptureResource(ArrayObject $model, Capture $request): ApiResource
    {
        $token = $this->getRequestToken($request);
        $model->offsetSet('success_url', $token->getTargetUrl());
        $model->offsetSet('cancel_url', $token->getTargetUrl());

        $createCheckoutSession = new CreateSession(
            $model->getArrayCopy(),
            ['stripe_account'=>'{{CONNECTED_STRIPE_ACCOUNT_ID}}']
         );
        $this->gateway->execute($createCheckoutSession);

        return $createCheckoutSession->getApiResource();
    }
}

Using Stripe js (Element) :


<?php

declare(strict_types=1);

namespace App\PayumStripe\Action\StripeJs;

use FluxSE\PayumStripe\Action\JsCaptureAction;
use Payum\Core\Bridge\Spl\ArrayObject;
use Payum\Core\Request\Capture;
use Stripe\ApiResource;
use Stripe\PaymentIntent;

final class CaptureThirdPartyAction extends JsCaptureAction
{
    protected function createCaptureResource(ArrayObject $model, Capture $request): ApiResource
    {
        $createPaymentIntent = new CreatePaymentIntent(
            $model->getArrayCopy(),
            ['stripe_account'=>'{{CONNECTED_STRIPE_ACCOUNT_ID}}']
         );
        $this->gateway->execute($createPaymentIntent);

        return $createPaymentIntent->getApiResource();
    }
}

NB : You can make another Action to be able to retrieve the required {{CONNECTED_STRIPE_ACCOUNT_ID}} or simply add it to your initial $payment->setDetails(...) array but remember to remove it from this array before creating the PaymentIntent or Session. Example :

$foreignAccountId = $model->offsetGet('CONNECTED_STRIPE_ACCOUNT_ID');
$model->offsetUnset('CONNECTED_STRIPE_ACCOUNT_ID');

$createPaymentIntent = new CreatePaymentIntent(
      $model->getArrayCopy(),
      $foreignAccountId
);
grandiandrea commented 3 years ago

Thank you so much for answering, this has been very helpful. I've been trying to make it work, and so far this is what I've got (for stripe_checkout_session):

final class CaptureThirdPartyAction extends CaptureAction
{
    protected function createCaptureResource(ArrayObject $model, Capture $request): ApiResource
    {
        $token = $this->getRequestToken($request);
        $model->offsetSet('success_url', $token->getTargetUrl());
        $model->offsetSet('cancel_url', $token->getTargetUrl());

        $foreignAccountId = $model->offsetGet('stripe_account');
        $model->offsetUnset('stripe_account');

        $createCheckoutSession = new CreateSession(
            $model->getArrayCopy(),
            ['stripe_account' => $foreignAccountId]
        );
        $this->gateway->execute($createCheckoutSession);

        return $createCheckoutSession->getApiResource();
    }
}

This is the details array I pass:

$payment->setDetails([
            'stripe_account' => 'acct_code',
            'application_fee_amount' => 50
            ]);

So with all this it recognizes the stripe_account parameter which is great, but it blocks saying this: Received unknown parameter: application_fee_amount

I have tried a lot of things here, but I'm just very confused by the payum system I think. If you could point me in the right direction, I would appreciate it a lot. Thank you again.

Prometee commented 3 years ago

The Stripe Session can't own this value, only the PaymentIntent can own it, that's why it's not working.

You have to add this field to the payment_intent_data field : https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_intent_data-application_fee_amount

$payment->setDetails([
    'stripe_account' => 'acct_code',
    'payment_intent_data' => [
        'application_fee_amount' => 50,
    ],
]);
grandiandrea commented 3 years ago

I have put the application fee in the right place, but now I get No such payment_intent: 'pi_yyyy. It seems to happen after CapturePaymentAction and ExecuteSameRequestWithModelDetailsAction. I think I need to provide the stripe_account in this second request too. Is there a good way to do it?

Prometee commented 3 years ago

@MotAtlas can you provide some stack trace ?

Reading the Stripe doc, I'm not sure you need again to provide the stripe_account at this point because the PaymentIntent will stay on your Stripe account, not on the foreign one, but I'm not sure of this I could be wrong.

grandiandrea commented 3 years ago

Of course, here is the stack trace:

Stripe\Exception\InvalidRequestException:
No such payment_intent: 'pi_1Iku549vAXRnKA4ncp7totdO'

  at vendor/stripe/stripe-php/lib/Exception/ApiErrorException.php:38
  at Stripe\Exception\ApiErrorException::factory()
     (vendor/stripe/stripe-php/lib/Exception/InvalidRequestException.php:35)
  at Stripe\Exception\InvalidRequestException::factory()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:189)
  at Stripe\ApiRequestor::_specificAPIError()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:151)
  at Stripe\ApiRequestor->handleErrorResponse()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:489)
  at Stripe\ApiRequestor->_interpretResponse()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:120)
  at Stripe\ApiRequestor->request()
     (vendor/stripe/stripe-php/lib/ApiResource.php:62)
  at Stripe\ApiResource->refresh()
     (vendor/stripe/stripe-php/lib/ApiOperations/Retrieve.php:26)
  at Stripe\PaymentIntent::retrieve()
     (vendor/flux-se/payum-stripe/src/Action/Api/Resource/AbstractRetrieveAction.php:42)
  at FluxSE\PayumStripe\Action\Api\Resource\AbstractRetrieveAction->retrieveApiResource()
     (vendor/flux-se/payum-stripe/src/Action/Api/Resource/AbstractRetrieveAction.php:25)
  at FluxSE\PayumStripe\Action\Api\Resource\AbstractRetrieveAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/flux-se/payum-stripe/src/Action/SyncAction.php:63)
  at FluxSE\PayumStripe\Action\SyncAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/flux-se/payum-stripe/src/Action/CaptureAction.php:54)
  at FluxSE\PayumStripe\Action\CaptureAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/core/Payum/Core/Action/CapturePaymentAction.php:40)
  at Payum\Core\Action\CapturePaymentAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/core/Payum/Core/Action/ExecuteSameRequestWithModelDetailsAction.php:36)
  at Payum\Core\Action\ExecuteSameRequestWithModelDetailsAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/payum-bundle/Controller/CaptureController.php:42)
  at Payum\Bundle\PayumBundle\Controller\CaptureController->doAction()
     (vendor/symfony/http-kernel/HttpKernel.php:157)
  at Symfony\Component\HttpKernel\HttpKernel->handleRaw()
     (vendor/symfony/http-kernel/HttpKernel.php:79)
  at Symfony\Component\HttpKernel\HttpKernel->handle()
     (vendor/symfony/http-kernel/Kernel.php:195)
  at Symfony\Component\HttpKernel\Kernel->handle()
     (public/index.php:20) 

Here is the response to POST | /v1/checkout/sessions:

{
  "id": "cs_test_a1E9KkAS9xEOAIDYxGD9y2uKSzWkgti3xIJ2ygFLSBePC46eOUboquvN6Y",
  "object": "checkout.session",
  "allow_promotion_codes": null,
  "amount_subtotal": 1000,
  "amount_total": 1000,
  "billing_address_collection": null,
  "cancel_url": "http://6708c9d59775.ngrok.io/payment/capture/em9yHfNpAursbgU_68w3mqu5skXBZiH15jzkBuQJCis",
  "client_reference_id": null,
  "currency": "eur",
  "customer": null,
  "customer_details": null,
  "customer_email": "user2@mail.com",
  "livemode": false,
  "locale": null,
  "metadata": {
    "token_hash": "hLNIHWG6pgNjmuda4s5EKuNcqqBlpPn-jsXCO3Fuw2w"
  },
  "mode": "payment",
  "payment_intent": "pi_1Iku549vAXRnKA4ncp7totdO",
  "payment_method_options": {
  },
  "payment_method_types": [
    "card"
  ],
  "payment_status": "unpaid",
  "setup_intent": null,
  "shipping": null,
  "shipping_address_collection": null,
  "submit_type": null,
  "subscription": null,
  "success_url": "http://6708c9d59775.ngrok.io/payment/capture/em9yHfNpAursbgU_68w3mqu5skXBZiH15jzkBuQJCis",
  "total_details": {
    "amount_discount": 0,
    "amount_shipping": 0,
    "amount_tax": 0
  }
}

Then a request to GET | /v1/payment_intents/pi_1Iku549vAXRnKA4ncp7totdO is fired, and it gets this back making the app crash:

{
  "error": {
    "code": "resource_missing",
    "doc_url": "https://stripe.com/docs/error-codes/resource-missing",
    "message": "No such payment_intent: 'pi_1Iku549vAXRnKA4ncp7totdO'",
    "param": "intent",
    "type": "invalid_request_error"
  }
}

And these two requests are the only ones I get in the stripe test logs. I have found this stack overflow answer, and it's why I figured the stripe_account could be needed in that second request too.

Thank you for getting back to me so many times, you are helping me a lot.

Prometee commented 3 years ago

@MotAtlas you're welcome, I wanted to enhance this library as far as I can, so getting new usage like yours is challenging but very interesting 😄

From what I understand reading the stack trace and the Stack Overflow link, I think setting this stripe_account should be done on every Stripe calls. This can be done like the Javascript example, using the Stripe\Stripe class used for example here : https://github.com/FLUX-SE/PayumStripe/blob/master/src/Action/Api/Resource/AbstractRetrieveAction.php#L37. I guess the account_id property is what you need and this property can be set globally :

Stripe::setAccountId('the_stripe_account');

I let you figure out how to retrieve the account, but I would add a dedicated class which old the stripe_account. Something named StripeAccountContext with a getter/setter getStripeAccount/setStripeAccount for example.

grandiandrea commented 3 years ago

I tried to set it with the Stripe api, as you said, but it's doing weird things. By setting 'payment_intent_data' => ['application_fee_amount' => 50] I get:

Stripe\Exception\InvalidRequestException:
Can only apply an application_fee_amount when the PaymentIntent is attempting a direct payment (using an OAuth key or Stripe-Account header) or destination payment (using `transfer_data[destination]`).

  at vendor/stripe/stripe-php/lib/Exception/ApiErrorException.php:38
  at Stripe\Exception\ApiErrorException::factory()
     (vendor/stripe/stripe-php/lib/Exception/InvalidRequestException.php:35)
  at Stripe\Exception\InvalidRequestException::factory()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:189)
  at Stripe\ApiRequestor::_specificAPIError()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:151)
  at Stripe\ApiRequestor->handleErrorResponse()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:489)
  at Stripe\ApiRequestor->_interpretResponse()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:120)
  at Stripe\ApiRequestor->request()
     (vendor/stripe/stripe-php/lib/ApiOperations/Request.php:63)
  at Stripe\ApiResource::_staticRequest()
     (vendor/stripe/stripe-php/lib/ApiOperations/Create.php:25)
  at Stripe\Checkout\Session::create()
     (vendor/flux-se/payum-stripe/src/Action/Api/Resource/AbstractCreateAction.php:45)
  at FluxSE\PayumStripe\Action\Api\Resource\AbstractCreateAction->createApiResource()
     (vendor/flux-se/payum-stripe/src/Action/Api/Resource/AbstractCreateAction.php:25)
  at FluxSE\PayumStripe\Action\Api\Resource\AbstractCreateAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/flux-se/payum-stripe/src/Action/CaptureAction.php:132)
  at FluxSE\PayumStripe\Action\CaptureAction->createCaptureResource()
     (vendor/flux-se/payum-stripe/src/Action/CaptureAction.php:45)
  at FluxSE\PayumStripe\Action\CaptureAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/core/Payum/Core/Action/CapturePaymentAction.php:40)
  at Payum\Core\Action\CapturePaymentAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/core/Payum/Core/Action/ExecuteSameRequestWithModelDetailsAction.php:36)
  at Payum\Core\Action\ExecuteSameRequestWithModelDetailsAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/payum-bundle/Controller/CaptureController.php:42)
  at Payum\Bundle\PayumBundle\Controller\CaptureController->doAction()
     (vendor/symfony/http-kernel/HttpKernel.php:157)
  at Symfony\Component\HttpKernel\HttpKernel->handleRaw()
     (vendor/symfony/http-kernel/HttpKernel.php:79)
  at Symfony\Component\HttpKernel\HttpKernel->handle()
     (vendor/symfony/http-kernel/Kernel.php:195)
  at Symfony\Component\HttpKernel\Kernel->handle()
     (public/index.php:20)

and in the stripe logs:

parameter_missing - application_fee_amount

Looks like you are missing the application_fee_amount field. This field allows the platform to take an application fee on direct charges. It can be any positive number up to the amount of the charge.

So it seems it needed to be outside the payment_intent_data, but doing so causes:

Stripe\Exception\InvalidRequestException:
Received unknown parameter: application_fee_amount

  at vendor/stripe/stripe-php/lib/Exception/ApiErrorException.php:38
  at Stripe\Exception\ApiErrorException::factory()
     (vendor/stripe/stripe-php/lib/Exception/InvalidRequestException.php:35)
  at Stripe\Exception\InvalidRequestException::factory()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:189)
  at Stripe\ApiRequestor::_specificAPIError()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:151)
  at Stripe\ApiRequestor->handleErrorResponse()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:489)
  at Stripe\ApiRequestor->_interpretResponse()
     (vendor/stripe/stripe-php/lib/ApiRequestor.php:120)
  at Stripe\ApiRequestor->request()
     (vendor/stripe/stripe-php/lib/ApiOperations/Request.php:63)
  at Stripe\ApiResource::_staticRequest()
     (vendor/stripe/stripe-php/lib/ApiOperations/Create.php:25)
  at Stripe\Checkout\Session::create()
     (vendor/flux-se/payum-stripe/src/Action/Api/Resource/AbstractCreateAction.php:45)
  at FluxSE\PayumStripe\Action\Api\Resource\AbstractCreateAction->createApiResource()
     (vendor/flux-se/payum-stripe/src/Action/Api/Resource/AbstractCreateAction.php:25)
  at FluxSE\PayumStripe\Action\Api\Resource\AbstractCreateAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/flux-se/payum-stripe/src/Action/CaptureAction.php:132)
  at FluxSE\PayumStripe\Action\CaptureAction->createCaptureResource()
     (vendor/flux-se/payum-stripe/src/Action/CaptureAction.php:45)
  at FluxSE\PayumStripe\Action\CaptureAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/core/Payum/Core/Action/CapturePaymentAction.php:40)
  at Payum\Core\Action\CapturePaymentAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/core/Payum/Core/Action/ExecuteSameRequestWithModelDetailsAction.php:36)
  at Payum\Core\Action\ExecuteSameRequestWithModelDetailsAction->execute()
     (vendor/payum/core/Payum/Core/Gateway.php:107)
  at Payum\Core\Gateway->execute()
     (vendor/payum/payum-bundle/Controller/CaptureController.php:42)
  at Payum\Bundle\PayumBundle\Controller\CaptureController->doAction()
     (vendor/symfony/http-kernel/HttpKernel.php:157)
  at Symfony\Component\HttpKernel\HttpKernel->handleRaw()
     (vendor/symfony/http-kernel/HttpKernel.php:79)
  at Symfony\Component\HttpKernel\HttpKernel->handle()
     (vendor/symfony/http-kernel/Kernel.php:195)
  at Symfony\Component\HttpKernel\Kernel->handle()
     (public/index.php:20)

With this in the Stripe logs:

parameter_unknown - application_fee_amount

The application_fee_amount field can only be set on payments tied to a platform’s connected accounts.

If you are not trying to integrate with Connect you do not need to set this field.

So I have a problem with the application_fee_amount that makes Symfony crash, and a problem with Stripe::setAccountId('acct_xxyy'); which I have been using before making the payment and apparently hasn't been working.

Prometee commented 2 years ago

Hello @MotAtlas,

A developper contacted me about your issue and we found a way to do what you need to do, here is the GIST you can use :

https://gist.github.com/Prometee/8c53130648c6381e75d27812b751d43c

If you are not using Sylius, you will only have to replace the PaymentInterface used to whatever you need. You will also have to add those action to the gateway config if you are not using Symfony.

The idea is to store the stripe_account into the Stripe Checkout Session and PaymentIntent metadata (here) Then during all future Sync the stripe_account will be detected and added as an option for each PaymentIntent::retrieve() or Session::retrieve() (here, here, and here)

We tested it and it's working perfectly.

Hope it helps you or another dev.

NB: Refund and other related stuff are not covered by this gist, also I don't know yet how webhooks are acting in this specific case, so test it carefully if you are using payment_methods different from ['card'].