Stripe Destination Charges
This post is a part of YAMAP Engineers Advent Calendar 2023.
Introduction
Imagine this situation: You've got an existing system with user subscriptions, powered by Stripe for payments. Now, you're introducing a new subsidiary offering additional services through the same system. The question arises: How do you design the payment system for this new venture?
Stripe provides a solution for multiple companies sharing the same customer base called Stripe Connect. There are three types of charges supporting various business models, with Destination Charges being the optimal choice for our situation. In this post, I'll share my experience integrating it.
Creating a payment
Setting up a PaymentIntent with Destination Charge isn't vastly different from a regular PaymentIntent. You just need to include the account where you want to send the money. Here's a quick code snippet in Ruby
Stripe::PaymentIntent.create({
amount: 1000,
currency: 'jpy',
transfer_data: {destination: 'acct_1Nv0FGQ9RKHgCVdK'}, # this line
})
When users make a payment, Stripe generates a Transfer object and an additional Charge object in the destination account, identifiable by an ID starting with py_
instead of the usual ch_
.
To access the Charge object of the destination account, you can utilize the Charges API with an extra account parameter:
Stripe::Charge.retrieve(
'py_something',
{ stripe_account: 'acct_1Nv0FGQ9RKHgCVdK' }
)
Syncing additional information
One challenge I faced was the lack of detailed payment information in the destination account.
I usually associate the payment record id in our system with the metadata
parameter of the PaymentIntent, and use the description
parameter for payment detail. This can be checked and used as a filter in the main account's dashboard.
Stripe::PaymentIntent.create({
amount: 1000,
currency: 'jpy',
description: 'base fee`,
metadata: { contract_id: 123 },
transfer_data: {destination: 'acct_1Nv0FGQ9RKHgCVdK'}, # this line
})
However, these attributes are considered private data of the main account, so Stripe will not automatically transfer the data to the destination account. You have to implement the data sync manually.
I did it by getting the py_something
Charge id from the charge.succeeded
webhook event, and calling the Charge update API to set descripiton
and metadata
of the Charge object.
event = Stripe::Webhook.construct_event(
payload, signature, endpoint_secret
)
charge = event.data.object
transfer = Stripe::Transfer.retrieve(charge.transfer)
Stripe::Charge.update(
transfer.destination_payment,
{ description: 'base fee', metadata: { contract_id: 123 } },
stripe_account: 'acct_1Nv0FGQ9RKHgCVdK'
)
Webhooks
Handling webhooks is another crucial aspect. Payments to both the main and subsidiary services trigger the same webhook event in your Stripe account. If you have one controller to handle the main service's webhooks, and another controller to handle the subsidiary's webhooks, they will receive both services' events. To differentiate between the two, you'll need to implement service detection in your controllers.
In my case, I only handle PaymentIntent and Charge events, so it's quite easy. In these events there will be the information about destination account.
// charge.succeeded
{
"object": {
"id": "ch_3OQ4bsBl4nMaPGI22r0x9er2",
"object": "charge",
"amount": 3000,
"amount_captured": 3000,
"amount_refunded": 0,
...
"destination": "acct_1My4cAPnOfYYQzQw",
...
"transfer_data": {
"amount": null,
"destination": "acct_1My4cAPnOfYYQzQw"
},
"transfer_group": "group_pi_3OQ4bsBl4nMaPGI22Bw37yLK"
}
}
// payment_intent.succeeded
{
"object": {
"id": "pi_3OQ4bsBl4nMaPGI22Bw37yLK",
"object": "payment_intent",
"amount": 3000,
...
"transfer_data": {
"destination": "acct_1My4cAPnOfYYQzQw"
},
"transfer_group": "group_pi_3OQ4bsBl4nMaPGI22Bw37yLK"
}
}
By examining the destination
and transfer_data
attributes in these events, you can identify whether the event pertains to the main or subsidiary service.
Conclusion
Incorporating Stripe Connect's Destination Charges into our payment system proved to be a seamless process, with some small challenges. I hope this post can be helpful when you face the same situation.
Discussion