Build your own marketplace from scratch using Medusa
In this last part of the series, we’ll be setting up Stripe and Stripe Connect to collect payments from our customers and trigger payments for our vendors.
We can now add our changes in the up and down functions :
src/migrations/...-add-stripe-account-to-store.ts
// ... export class AddStripe... public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`ALTER TABLE "store" ADD "stripe_account_id" character varying`) await queryRunner.query(`ALTER TABLE "store" ADD "stripe_account_enabled" boolean NOT NULL DEFAULT false`) } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`ALTER TABLE "store" DROP COLUMN "stripe_account_id"`) await queryRunner.query(`ALTER TABLE "store" DROP COLUMN "stripe_account_enabled"`) }// ... } ...
Don’t forget to apply the migrations once you have updated the file by executing this in your terminal :
yarn buildnpx medusa migrations run
Whenever you add a new column / property to an entity that have been extended don’t forget to update your index.d.ts in case you are going to use that property, or it will throw TypeScript errors.
With all this groundwork done, we’re finally going to create the StripeConnectService. I invite you to create a new file in the /src/services folder with the name stripe-connect.ts.
Once the file has been created, here’s what you’ll need to paste inside it for now :
src/services/stripe-connect.ts
import { TransactionBaseService, type ConfigModule as MedusaConfigModule } from '@medusajs/medusa'import { Lifetime } from 'awilix'import { MedusaError } from 'medusa-core-utils'import type StripeBase from 'medusa-payment-stripe/dist/core/stripe-base'import type { Stripe } from 'stripe'import type { Repository } from 'typeorm'import type { Store } from '../models/store'type ConfigModule = MedusaConfigModule & { projectConfig: MedusaConfigModule['projectConfig'] & { server_url: string }}type InjectedDependencies = { stripeProviderService: StripeBase storeRepository: Repository<Store> configModule: ConfigModule}class StripeConnectService extends TransactionBaseService { static identifier = 'stripe-connect' static LIFE_TIME = Lifetime.SINGLETON private readonly stripe_: Stripe private readonly serverUrl_: string private readonly storeRepository_: Repository<Store> constructor(container: InjectedDependencies) { super(container) this.stripe_ = container.stripeProviderService.getStripe() this.serverUrl_= container.configModule.projectConfig.server_url this.storeRepository_ = container.storeRepository } async createTransfer(data: Stripe.TransferCreateParams): Promise<Stripe.Transfer> { const transfer = await this.stripe_.transfers.create(data) return transfer } async createAccount(storeId: string): Promise<Stripe.Response<Stripe.Account>> { return await this.atomicPhase_(async (m) => { const storeRepo = m.withRepository(this.storeRepository_) const store = await storeRepo.findOne({ where: { id: storeId } }) if (!store) { throw new MedusaError(MedusaError.Types.NOT_FOUND, 'Store not found') } if (store.stripe_account_id) { return await this.stripe_.accounts.retrieve(store.stripe_account_id) } const accountToken = await this.stripe_.tokens.create({ account: { business_type: 'company', company: { name: store.name, }, tos_shown_and_accepted: true, }, }) const account = await this.stripe_.accounts.create({ type: 'custom', country: 'YOUR_COUNTRY_CODE', // Replace with a supported country code, e.g. 'FR', 'US', 'ES' etc. account_token: accountToken.id, capabilities: { card_payments: { requested: true, }, transfers: { requested: true, }, }, }) await storeRepo.update( storeId, { stripe_account_id: account.id } ) return account }) } async createOnboardingLink(storeId: string) { const url = `${this.serverUrl_}/stripe/onboarding` return await this.atomicPhase_(async (m) => { const storeRepo = m.withRepository(this.storeRepository_) const store = await storeRepo.findOne({ where: { id: storeId } }) if (!store) { throw new MedusaError(MedusaError.Types.NOT_FOUND, 'Store not found') } if (!store.stripe_account_id) { throw new MedusaError(MedusaError.Types.NOT_FOUND, 'Stripe account not found') } if (store.stripe_account_enabled) { throw new MedusaError(MedusaError.Types.NOT_ALLOWED, 'Stripe account already enabled') } const accountLink = await this.stripe_.accountLinks.create({ account: store.stripe_account_id, type: 'account_onboarding', refresh_url: `${url}/refresh?storeId=${store.id}`, return_url: `${url}/return?storeId=${store.id}`, }) if(!store.metadata) { store.metadata = { stripe_onboarding_url: accountLink.url } } else { store.metadata.stripe_onboarding_url = accountLink.url } await storeRepo.save(store) }) } async retrieveStripeAccount(storeId: string) : Promise<Stripe.Account> { const stripeAccountId = (await this.storeRepository_.findOne({ where: { id: storeId } })).stripe_account_id const stripeAccount = await this.stripe_.accounts.retrieve(stripeAccountId) return stripeAccount }}export default StripeConnectService
This service contains all the logic we need to carry out the following process:
When creating a store, we’ll use the StripeConnectService.createAccount function, which will allow us to create a Stripe account for the store in question.
Once the Stripe account has been created, you can then create an onboarding link for the Store using the StripeConnectService.createOnboarding. This link will be saved directly in the store metadata in our case, but you can also add a new property to your Store entity that will store the onboarding url if you want.
Finally, once the Stripe account of a store is active, we’ll be able to trigger payments using the StripeConnect.createTransfer function once an order meets our conditions. In this example, we’ll trigger a transfer when an order has been delivered and paid, of course you can adapt to your needs.
Don’t forget to change theYOUR_COUNTRY_CODEvalue in theStripeConnectService.createAccountfunction
As mentioned above, we’re going to use our new service, when a new store is created.
To do this, we’ll update our Subscriber responsible for monitoring the StoreService.Events.CREATED event :
src/subscribers/store-created.ts
import type { Logger, MedusaContainer, SubscriberArgs, SubscriberConfig } from '@medusajs/medusa'import type { EntityManager } from 'typeorm'import type ShippingProfileService from '../services/shipping-profile'import type StripeConnectService from '../services/stripe-connect'import StoreService from '../services/store'export default async function handleStoreCreated({ data, eventName, container, pluginOptions,}: SubscriberArgs<Record<string, string>>) { const logger = container.resolve<Logger>("logger") const shippingProfileActivity = logger.activity(`Creating default shipping profile for store ${data.id}`) await createDefaultShippingProfile(data.id, container).catch(e => { logger.failure(shippingProfileActivity, `Error creating default shipping profile for store ${data.id}`) throw e }) logger.success(shippingProfileActivity, `Default shipping profile for store ${data.id} created`) const stripeAccountActivity = logger.activity(`Creating stripe account for store ${data.id}`) await createStripeAccount(data.id, container).catch(e => { logger.failure(stripeAccountActivity, `Error creating stripe account for store ${data.id}`) throw e }) logger.success(stripeAccountActivity, `Stripe account for store ${data.id} created`) const stripeOnboardingActivity = logger.activity(`Creating stripe onboarding link for store ${data.id}`) await createStripeOnboardingLink(data.id, container).catch(e => { logger.failure(stripeOnboardingActivity, `Error creating stripe onboarding link for store ${data.id}`) throw e }) logger.success(stripeOnboardingActivity, `Stripe onboarding link for store ${data.id} created`)}async function createDefaultShippingProfile(storeId: string, container: MedusaContainer) { const manager = container.resolve<EntityManager>("manager") const shippingProfileService = container.resolve<ShippingProfileService>("shippingProfileService") return await manager.transaction(async (m) => { await shippingProfileService.withTransaction(m).createDefaultForStore(storeId) })}async function createStripeAccount(storeId: string, container: MedusaContainer) { const manager = container.resolve<EntityManager>("manager") const stripeConnectService = container.resolve<StripeConnectService>("stripeConnectService") return await manager.transaction(async (m) => { await stripeConnectService.withTransaction(m).createAccount(storeId) })}async function createStripeOnboardingLink(storeId: string, container: MedusaContainer) { const manager = container.resolve<EntityManager>("manager") const stripeConnectService = container.resolve<StripeConnectService>("stripeConnectService") return await manager.transaction(async (m) => { await stripeConnectService.withTransaction(m).createOnboardingLink(storeId) })}export const config: SubscriberConfig = { event: StoreService.Events.CREATED, context: { subscriberId: 'store-created-handler', },}
Here we’ve refactored our code a little bit, to have three different functions triggered in order when a store is created, we also have a logger that will allows us to understand easily what’s going on.
First it will create the default shipping profile for the new store, then it will create a Stripe account for that store and finally the onboarding link will be created and saved into the Store metadata, so we’ll use it inside our Admin UI.
As you may have noticed, in our service we’re extending Medusa’s configuration and we’ve added a new property: server_url
In fact, this property doesn’t exist by default, so you can add it directly to your configuration in this way to point to the url of your Medusa backend so that Stripe can redirect you back to this URL in the case of a success or error following their onboarding process.
If you try to create a new user, you should see something like this in your terminal after a few seconds :
Perfect, if you take a look at your store table in your database, you should see a store with not null metadata column, which contains the link to start Stripe’s onboarding process.
On the other hand, before starting a process, we need to add the two routes that will handle the success/leave and error cases
For this section, I invite you to create a new file in the /src/api/stripe/onboarding/return/route.ts folder :
src/api/stripe/onboarding/return/route.ts
import type { Logger, MedusaRequest, MedusaResponse } from '@medusajs/medusa'import type { EntityManager } from 'typeorm'import { Store } from '../../../../models/store'import type StripeConnectService from '../../../../services/stripe-connect'export async function GET(req: MedusaRequest, res: MedusaResponse) { const storeId = req.query.storeId const logger = req.scope.resolve<Logger>('logger') if (!storeId) { logger.error('Stripe Onboarding Return Route: Missing storeId') return res.status(400).json({ error: 'Missing storeId' }) } const stripeConnectService = req.scope.resolve<StripeConnectService>('stripeConnectService') const stripeAccount = await stripeConnectService.retrieveStripeAccount(storeId as string) if (!stripeAccount.details_submitted || !stripeAccount.payouts_enabled) { // Redirect to admin dashboard on onboarding not completed res.redirect("http://localhost:7001") return } const manager: EntityManager = req.scope.resolve<EntityManager>('manager') await manager.transaction(async (manager) => { const storeRepo = manager.getRepository(Store) let store = await storeRepo.findOne({ where: { id: storeId as string } }) if (!store) { logger.error('Stripe Onboarding Return Route: Store not found') return res.status(404).json({ error: 'Store not found' }) } if (store.stripe_account_enabled) { logger.error('Stripe Onboarding Return Route: Stripe account already enabled') return res.status(400).json({ error: 'Stripe account already enabled' }) } store.stripe_account_enabled = true store = await storeRepo.save(store) }) // Redirect to admin dashboard on success res.redirect("http://localhost:7001")}
Here, in the case of a process success we retrieve the storeId from the query parameters, and then set the store.stripe_account_enabled value to true for that store. Once the value has been updated, we can redirect the user to the admin UI
But in case the user has just leaved the onboarding process, we do nothing and just redirect.
Of course, you can use a constant here or even the config to make sure it’s not hardcoded like this, here, it’s just for the example.
I suggest that you make sure that the storefront can’t access products from stores that haven’t activated their Stripe account, to make sure that all stores are “activated” before they can proceed with sales on our platform.
src/services/product.ts
// ...class ProductService extends MedusaProductService {// ... async listAndCount(selector: ProductSelector, config?: FindProductConfig): Promise<[Product[], number]> { if (!selector.store_id && this.loggedInUser_?.store_id) { selector.store_id = this.loggedInUser_.store_id } config.select?.push('store_id') config.relations?.push('store') const [products, count] = await super.listAndCount(selector, config) if (!this.loggedInUser_) { // In case we don't have a loggedInUser, we can deduce that it's a storefront API call // So we filter out the products that have a store with a Stripe account not enabled return [products.filter((p) => p.store.stripe_account_enabled), count] } return [products, count] }// ...}
We will initiate money transfers only when an order has been marked as shipped and its overall status is set to completed.
However, Medusa does not automatically update an order’s status to completed when the order is shipped and the payment is captured.
To address this, we need to create a new subscriber that monitors the payment status and fulfillment status. Based on these statuses, we will update the overall order status to completed.
By implementing this new subscriber, we can specifically track when an order is truly completed. Once the order is marked as completed, we can then trigger the money transfer process using our StripeConnectService.createTransfer
This subscriber will monitor the events of the child orders (to be clearer, it will monitor the changes made by the stores), if an order has the status of payment captured, and its order has been marked as shipped, then we can update the child order’s status to completed and create a transfer for the order’s store :
src/subscribers/child-order-updated.ts
import { FulfillmentStatus, Logger, MedusaContainer, OrderStatus, PaymentStatus, type SubscriberArgs, type SubscriberConfig,} from '@medusajs/medusa'import type { Order } from '../../models/order'import OrderService from '../../services/order'import StripeConnectService from 'src/services/stripe-connect'export default async function handleOrderUpdated({ data, eventName, container, pluginOptions,}: SubscriberArgs<Record<string, string>>) { const logger = container.resolve<Logger>('logger') const orderService: OrderService = container.resolve('orderService') const order = await orderService.retrieve(data.id) if (!order.order_parent_id) { return } const updateActivity = logger.activity(`Updating child order statuses for the parent order ${order.order_parent_id}...`) await updateStatusOfChildren({ container, parentOrderId: order.order_parent_id, updateActivity, logger }) logger.success(updateActivity, `Child order statuses updated for the parent order ${order.order_parent_id}.`)}/** * This function is executed when a child order is updated. * It checks if the child order has a payment status of "captured" and a fulfillment status of "shipped". * If both conditions are met, it updates the child order's status to "complete", allowing a parent order to be marked as "complete" too. * But we also create a Stripe transfer for the store. */type Options = { container: MedusaContainer, parentOrderId: string, updateActivity: void, logger: Logger}async function updateStatusOfChildren({ container, parentOrderId, logger, updateActivity}: Options) { const orderService = container.resolve<OrderService>('orderService') const stripeConnectService = container.resolve<StripeConnectService>('stripeConnectService') const parentOrder = await orderService.retrieve(parentOrderId, { relations: ['children'], }) if (!parentOrder.children) { return } const ordersToComplete = parentOrder.children .filter((child) => child.payment_status === PaymentStatus.CAPTURED || child.payment_status === PaymentStatus.PARTIALLY_REFUNDED || child.payment_status === PaymentStatus.REFUNDED) .filter((child) => child.fulfillment_status === FulfillmentStatus.SHIPPED) .filter( (child) => child.status !== OrderStatus.CANCELED && child.status !== OrderStatus.ARCHIVED && child.status !== OrderStatus.COMPLETED, ) if (ordersToComplete.length === 0) { return } for (const order of ordersToComplete) { await orderService.completeOrder(order.id) const childOrder = await orderService.retrieveWithTotals(order.id, { relations: ['store'] }) await stripeConnectService.createTransfer({ amount: childOrder.total - childOrder.refunded_total, currency: childOrder.currency_code, destination: childOrder.store.stripe_account_id, metadata: { orderId: childOrder.id, orderNumber: childOrder.display_id, }, }).catch((e) => { logger.failure(updateActivity, `An error has occured while creating the Stripe transfer for order ${order.id}.`) throw e }) }}export const config: SubscriberConfig = { event: [ OrderService.Events.UPDATED, OrderService.Events.FULFILLMENT_CREATED, OrderService.Events.FULFILLMENT_CANCELED, OrderService.Events.GIFT_CARD_CREATED, OrderService.Events.ITEMS_RETURNED, OrderService.Events.PAYMENT_CAPTURED, OrderService.Events.PAYMENT_CAPTURE_FAILED, OrderService.Events.REFUND_CREATED, OrderService.Events.REFUND_FAILED, OrderService.Events.RETURN_ACTION_REQUIRED, OrderService.Events.RETURN_REQUESTED, OrderService.Events.SHIPMENT_CREATED, OrderService.Events.SWAP_CREATED, ], context: { subscriberId: 'child-order-updated-handler', },}
Please note that this is purely for educational purposes.
In a real marketplace, you wouldn’t make transfers directly like this, it implies more thoughts.
For example, a transfer could be triggered after a certain time to make sure that no refund could be requested from the customer.
Take a look at the Scheduled Job with Medusa
Now that the backend is in place, we’re going to tackle the Admin UI, so that our vendors can begin the process of onboarding with Stripe to be able to receive transfers later on.
You can create a new widget file at the following path /src/admin/widgets/stripe-onboarding-widget.tsx :
Throughout this series, we’ve explored the process of extending and customizing Medusa.
Leveraging Medusa’s flexibility, we’ve enabled users/vendors to create products, define shipping options, manage orders individually and integrate with Stripe for payouts.
While our course has been a rewarding one, it’s important to recognize that we’ve only scratched the surface of Medusa’s capabilities.
Platforms such as Amazon and Etsy offer a wide range of features and functionality to meet a variety of “business requirements”, however, the knowledge and skills you’ve gained in this series have provided you with the foundation you need to further develop Medusa’s core functionality and apply advanced concepts to your project!