Medusa Marketplace #5 | Stripe Connect

Medusa Marketplace #5 | Stripe Connect

·

17 min read

Hello everyone!

In this new part, we'll be setting up Stripe and Stripe Connect to collect payments from our customers and trigger payments for our vendors.

Updating the Store entity

First, we'll add two new properties to the Store entity :

  • The first property will be the Stripe account ID linked to a store

  • The second property will indicate whether the Stripe account is active and ready to receive payments.

// src/models/store.ts

import { Column, Entity, OneToMany } from 'typeorm'

import { Store as MedusaStore } from '@medusajs/medusa'

import { Product } from './product'
import { User } from './user'
import { ShippingOption } from './shipping-option'
import { ShippingProfile } from './shipping-profile'
import { Order } from './order'

@Entity()
export class Store extends MedusaStore {
    @OneToMany(() => User, (user) => user.store)
    members?: User[]

    @OneToMany(() => Product, (product) => product.store)
    products?: Product[]

    @OneToMany(() => ShippingOption, (shippingOption) => shippingOption.store)
    shippingOptions?: ShippingOption[]

    @OneToMany(() => ShippingProfile, (shippingProfile) => shippingProfile.store)
    shippingProfiles?: ShippingProfile[]

    @OneToMany(() => Order, (order) => order.store)
    orders?: Order[]

    @Column({ nullable: true })
    stripe_account_id?: string

    @Column({ default: false })
    stripe_account_enabled: boolean
}

Creating the Migration

We can now apply our data to the database by creating our migration :

npx typeorm migration:create src/migrations/add-stripe-account-to-store

We can now add our changes in the up and down functions :

// src/migrations/<TIMESTAMP>-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 build
npx 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.

Configuring Medusa

In your medusa-config.js at the root of your backend directory, we'll setup the medusa-payment-stripe so that we can use it later :

// ... others

const plugins = [
  `medusa-fulfillment-manual`,
  `medusa-payment-manual`,
  {
    resolve: `@medusajs/file-local`,
    options: {
      upload_dir: "uploads",
    },
  },
  {
    resolve: "@medusajs/admin",
    /** @type {import('@medusajs/admin').PluginOptions} */
    options: {
      autoRebuild: true,
      develop: {
        open: process.env.OPEN_BROWSER !== "false",
      },
    },
  },
  {
    resolve: `medusa-payment-stripe`,
    options: {
      api_key: process.env.STRIPE_SECRET_KEY,
      capture: true, // For auto capturing the payments
    },
  },
];

// ... others

Adding the medusa-payment-stripe dependency

Before continuing, make sure you have the medusa-payment-stripe package available in your project, otherwise you can install it this way:

yarn add medusa-payment-stripe

Creating the Service

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 :

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 the YOUR_COUNTRY_CODE value in the StripeConnectService.createAccount function

Use the service

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 :

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.

Update (again) our Medusa config

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.

// medusa-config.js

// ... others

/** @type {import('@medusajs/medusa').ConfigModule["projectConfig"]} */
const projectConfig = {
  jwtSecret: process.env.JWT_SECRET,
  cookieSecret: process.env.COOKIE_SECRET,
  store_cors: STORE_CORS,
  database_url: DATABASE_URL,
  admin_cors: ADMIN_CORS,
  server_url: process.env.SERVER_URL || 'http://localhost:9000'
  // Uncomment the following lines to enable REDIS
  // redis_url: REDIS_URL
};

// ...

Testing our service flow

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

Adding the Stripe API routes

Create the Success / Leave route

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.

Create the Error route

For this route, in the same /src/api/stripe/onboarding folder, I invite you to create a refresh folder containing this route.ts file :

// src/api/stripe/onboarding/refresh/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 manager: EntityManager = req.scope.resolve<EntityManager>('manager')

    let redirectUrl = ''

    await manager.transaction(async (m) => {
        const stripeConnectService = req.scope.resolve<StripeConnectService>('stripeConnectService')
        const storeRepo = m.getRepository(Store)

        await stripeConnectService.withTransaction(m).createOnboardingLink(storeId as string)

        const store = await storeRepo.findOne({ where: { id: storeId as string } })

        redirectUrl = store.metadata.stripe_onboarding_url as string
    })

    res.redirect(redirectUrl)
}

Here, in the case of an error such as an expired URL, we can generate a new onboarding URL and redirect the user directly to it.

💡

Update the ProductService

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]
    }
// ...
}

Transfers

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

Create the Child Order Subscriber

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 :

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.

Creating an Admin Widget

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 :

import type { WidgetConfig } from "@medusajs/admin"
import type { SVGProps } from 'react'

import { useAdminStore, useLocalStorage } from 'medusa-react'

const StripeOnboardingWidget = () => {

    const { store } = useAdminStore()

    const [shouldHideMessage, setHideMessage] = useLocalStorage("dismiss_stripe_message", "false")

    if (!store || !store.metadata || !store.metadata.stripe_onboarding_url) return null

    if (store.stripe_account_enabled && shouldHideMessage === "false"){
        return <AccountActivatedDisplay hideMessage={() => setHideMessage("true")} />
    }

    if (!store.stripe_account_enabled) {
        return <ActivateAccountDisplay link={store.metadata.stripe_onboarding_url as string} />
    }

    return null
}


const ActivateAccountDisplay = ({ link }: { link: string }) => {
    return (
        <div className="bg-white/60 backdrop-blur-sm border-b-2 py-6 px-32 min-h-[10rem] fixed top-0 right-0 w-full flex items-center justify-between max-w-[calc(100%-240px)]">
            <div className="flex items-center gap-4">
                <WarningIcon className="text-amber-500" />
                <div className="space-1.5">
                    <h1 className="font-medium text-lg tracking-tight text-grey-80">Activate your Stripe Account</h1>
                    <p className="text-sm text-grey-50">
                        Activate your account to start selling your products and make a profit on the perseides platform.
                    </p>
                </div>
            </div>
            <a href={link} className="px-4 h-9 bg-grey-90 text-white rounded flex items-center text-sm font-medium hover:bg-grey-70 transition-colors duration-300">
                Activate Now
            </a>
        </div>
    )
}

const AccountActivatedDisplay = ({ hideMessage }: { hideMessage: () => void }) => {
    return (
        <div className="bg-white/60 backdrop-blur-sm border-b-2 py-6 px-32 min-h-[10rem] fixed top-0 right-0 w-full flex items-center justify-between max-w-[calc(100%-240px)]">
            <div className="flex items-center gap-4">
                <CheckIcon className="text-green-500" />
                <div className="space-1.5">
                    <h1 className="font-medium text-lg tracking-tight text-grey-80">Your account has been activated</h1>
                    <p className="text-sm text-grey-50">
                        Welcome to perseides, your products are now visible on the storefront and available to buy!
                    </p>
                </div>
            </div>
            <button onClick={hideMessage} className="px-4 h-9 bg-grey-90 text-white rounded flex items-center text-sm font-medium hover:bg-grey-70 transition-colors duration-300">
                Dismiss
            </button>
        </div>
    )
}

const WarningIcon = (props: SVGProps<SVGSVGElement>) => (<svg
    width="24"
    height="24"
    viewBox="0 0 24 24"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    {...props}
>
    <path
        d="M12 6C12.5523 6 13 6.44772 13 7V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V7C11 6.44772 11.4477 6 12 6Z"
        fill="currentColor"
    />
    <path
        d="M12 16C11.4477 16 11 16.4477 11 17C11 17.5523 11.4477 18 12 18C12.5523 18 13 17.5523 13 17C13 16.4477 12.5523 16 12 16Z"
        fill="currentColor"
    />
    <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12Z"
        fill="currentColor"
    />
</svg>)

const CheckIcon = (props: SVGProps<SVGSVGElement>) => (<svg
    width="24"
    height="24"
    viewBox="0 0 24 24"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
    {...props}
>
    <path
        d="M10.2426 16.3137L6 12.071L7.41421 10.6568L10.2426 13.4853L15.8995 7.8284L17.3137 9.24262L10.2426 16.3137Z"
        fill="currentColor"
    />
    <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"
        fill="currentColor"
    />
</svg>)

export const config: WidgetConfig = {
    zone: ["order.list.before", "product.list.before"],
}

export default StripeOnboardingWidget

Here we're injecting this widget in several places, the product and order pages.

The orders page, in particular, is the one that appears following a login, perfect for attracting our vendor's attention!

💡
Other areas are available for injection, you can find them all here

You should see the widget like this when you log in with a Store that do not have activated it's account.

And by click on the Activate Now button, it should redirect you directly to the Stripe page.

In order to be displayed in this way at the end of the process :

Recap

Throughout this series, we've explored the process of extending and customizing Medusa.

Leveraging Medusa's flexibility, we've enabled users/vendors (call them how you like haha) 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!

Support my work

If you've enjoyed this series, or if you'd like to see other parts, feel free to buy me a coffee by clicking here, or become a sponsor of the perseides blog by clicking here

GitHub Branch

You can access the complete part's code here.

You can also have access to the storefront code here.

Contact

You can contact me on Discord and X with the same username : @adevinwild

Did you find this article valuable?

Support perseides by becoming a sponsor. Any amount is appreciated!