How To Implement a Referral System in Your Medusa Store

How To Implement a Referral System in Your Medusa Store

Referral systems are a powerful marketing tool that leverage existing customers to promote a product or service to their network. Businesses can tap into their loyal customer base by rewarding customers to refer others and expand their reach organically.

In this tutorial, you will explore how to implement a referral system in your Medusa e-commerce store. By implementing this feature, your customers will have the ability to refer other users and receive discounts on their future purchases. Additionally, the referred customers will also be eligible for a discount when they use a referral code during the sign-up process.

Here is a quick demo of what you're going to build in this tutorial:

You can find the code for the tutorial in this repository.

What is Medusa?

Medusa is an open source, composable commerce platform designed for developers. With its flexible architecture, Medusa enables users to create custom commerce solutions that meet their specific needs. It provides modular commerce infrastructure that makes custom development processes easier for developers. Medusa leverages cutting-edge infrastructure technologies such as serverless and edge workers to ensure scalability, reliability, and performance.

Medusa Architecture

The highly extensible architecture enables developers to easily create custom features, such as automating customer group assignments and integrating ChatGPT to automate writing product descriptions. The concepts like Subscribers and Events architecture in Medusa make it fairly simple to extend the store capabilities.

Learn more about the architecture of Medusa in their official documentation.

Prerequisites

Before you get started with the tutorial, you should have installed:

Setting up the Medusa Backend

The Medusa Backend serves as the central component responsible for the store's logic and data management. It exposes REST APIs that are utilized by the Medusa Admin and Storefront to perform operations like retrieving, creating, and modifying data.

In this section, you will go through setting up your Medusa backend. Follow these steps to create a new Medusa store named "my-medusa-store":

medusa new my-medusa-store

Open the .env file present in the my-medusa-store directory and add the database URL as below:

JWT_SECRET=something
COOKIE_SECRET=something

DATABASE_TYPE="postgres"
DATABASE_URL="postgres://<your-username>:<your-password>@localhost:5432/<your-database>"
REDIS_URL=redis://localhost:6379

Note: Replace the your-username, your-password and your-database with your own values.

Next, open the medusa-config.js file and uncomment the redis_url in the projectConfig object:

/** @type {import('@medusajs/medusa').ConfigModule["projectConfig"]} */
const projectConfig = {
  jwtSecret: process.env.JWT_SECRET,
  cookieSecret: process.env.COOKIE_SECRET,
  database_database: "./medusa-db.sql",
  database_type: DATABASE_TYPE,
  store_cors: STORE_CORS,
  admin_cors: ADMIN_CORS,
  // Uncomment the following lines to enable REDIS
  redis_url: REDIS_URL
}

Now, run the migrations and seed the test data present in the data/seed.json file to the PostgreSQL database:

cd my-medusa-store
medusa migrations run
medusa seed -f ./data/seed.json

Once the data is seeded, start the Medusa server by running this command:

medusa develop

These steps may take a few minutes to set up and run the server on port 9000. To test your server, you can either visit localhost:9000/store/products in your browser or use the following command:

curl localhost:9000/store/products

If your server has been set up successfully, you will receive a response displaying a list of products and related details.

Create a Referral Entity

The referral entity is an essential component of the referral system in Medusa. It represents a referral in the system and stores relevant information such as the referral code and the referrer customer. Let's explore why we need a referral entity and how to create it in the backend.

Start by creating a new referral.ts file in the src/models directory. This file will define the structure and behavior of your referral entity.

import { BaseEntity } from "@medusajs/medusa"
import { Customer } from "@medusajs/medusa/dist/models"
import {
    Column,
    Entity,
    JoinColumn,
    OneToOne
} from "typeorm"

@Entity()
export class Referral extends BaseEntity {
    @Column({ type: "varchar" })
    referral_code: string | null

    @OneToOne(() => Customer)
    @JoinColumn({ name: "referrer_customer_id" })
    referrer_customer: Customer
}

In the above code, the Referral entity class extends the BaseEntity provided by Medusa to automatically inherits additional columns such as id, created_at, and updated_at. In addition to that, the entity includes the following attributes:

  • referral_code: A string column that represents the referral code associated with the referral.

  • referrer_customer: A one-to-one relationship with the Customer entity, representing the customer who referred others.

Medusa uses a specific format for entity IDs, which is <PREFIX>_<RANDOM>. For example, a referral might have an ID like referral_01G35WVGY4D1JCA4TPGVXPGCQM.

To generate an ID for the Referral entity that matches the IDs generated for Medusa's core entities, you need to add a BeforeInsert event handler. Inside this handler, you can use the generateEntityId utility function provided by Medusa to generate the ID. The generateEntityId function takes the current ID as the first parameter and the desired prefix as the second parameter.

import { BaseEntity } from "@medusajs/medusa"
import { Customer } from "@medusajs/medusa/dist/models"
import { generateEntityId } from "@medusajs/medusa/dist/utils"
import {
    BeforeInsert,
    Column,
    Entity,
    JoinColumn,
    OneToOne
} from "typeorm"

@Entity()
export class Referral extends BaseEntity {
    @Column({ type: "varchar" })
    referral_code: string | null

    @OneToOne(() => Customer)
    @JoinColumn({ name: "referrer_customer_id" })
    referrer_customer: Customer

    @BeforeInsert()
    private beforeInsert(): void {
        this.id = generateEntityId(this.id, "referral")
    }
}

Create a Migration

Since you created a new entity, you need to create a migration for your entity to update the database schema with the new table. To create a migration that makes changes to your Medusa schema, run the following command:

npx typeorm migration:create src/migrations/ReferralCreated

This will create the migration file in the specified path. The contents of the file looks like the below:

import { MigrationInterface, QueryRunner } from "typeorm"

export class ReferralCreated1686235820432 implements MigrationInterface {

    public async up(queryRunner: QueryRunner): Promise<void> {
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
    }
}

In the above code, the class implements the MigrationInterface from TypeORM, which requires the implementation of two methods: up and down. These methods define the actions to be performed when applying or reverting the migration, respectively.

Next, implement the up and down methods as below:

import { MigrationInterface, QueryRunner, Table, TableForeignKey } from "typeorm"

export class ReferralCreated1686235820432 implements MigrationInterface {

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.createTable(
            new Table({
                name: "referral",
                columns: [
                    {
                        name: "id",
                        type: "varchar",
                        isPrimary: true,
                    },
                    {
                        name: "referral_code",
                        type: "varchar",
                        isNullable: true,
                    },
                    {
                        name: "referrer_customer_id",
                        type: "varchar",
                    },
                    {
                        name: "created_at",
                        type: "timestamp",
                        default: "now()",
                    },
                    {
                        name: "updated_at",
                        type: "timestamp",
                        default: "now()",
                    },
                ],
            })
        );

        await queryRunner.createForeignKey(
            "referral",
            new TableForeignKey({
                columnNames: ["referrer_customer_id"],
                referencedColumnNames: ["id"],
                referencedTableName: "customer",
                onDelete: "CASCADE",
            })
        );
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.dropTable("referral");
    }

}

The up method performs two operations - create a new referral table with the defined columns and create the foreign key constraint on the referrer_customer_id column, referencing the id column of the customer table. The down method simply drops the referral table.

Create a Repository

To conveniently access and modify data related to the Referral entity, you can create a TypeORM repository. Create a file named referral.ts in the src/repositories directory and add the following code to the file:

import { dataSource } from "@medusajs/medusa/dist/loaders/database";
import { Referral } from "../models/referral";

export const ReferralRepository = dataSource
    .getRepository(Referral);

export default ReferralRepository;

The ReferralRepository is created using the getRepository method provided by the dataSource exported from the core package in Medusa.

Run Migrations

Before you start using your entity, make sure to run the migrations that reflect the entity on your database schema. But before that, transpile the TypeScript files to JavaScript files by running the build command:

npm run build

Once the build process is complete, run the migration using the following command:

medusa migrations run

By creating the referral entity and repository, you have established the foundation for managing referral data in the backend. In the subsequent sections, you will build upon this by implementing services, APIs, and event subscribers to handle referral creation, validation, and updates.

Create a Referral Service

The referral service class contains different methods for creating and saving new referrals to the database. To create a referral service, create a referral.ts file in the src/services folder and add the following code:

import { TransactionBaseService } from "@medusajs/medusa"
import { EntityManager } from "typeorm"
import ReferralRepository from "../repositories/referral"

type InjectedDependencies = {
    manager: EntityManager
    referralRepository: typeof ReferralRepository
}

class ReferralService extends TransactionBaseService {
    protected readonly referralRepository_: typeof ReferralRepository

    constructor({
        referralRepository
    }: InjectedDependencies) {
        super(arguments[0])
        this.referralRepository_ = referralRepository
    }

}

export default ReferralService

The ReferralService class extends the TransactionBaseService class. This allows the referral service to inherit and utilize the transaction-related methods and functionalities provided by the base service.

The constructor of the ReferralService class accepts an object that contains the injected dependencies. It calls the super method with arguments[0] to pass the dependencies to the parent class constructor. Additionally, it assigns the referralRepository to the referralRepository_ property.

Next, create a method to save new referral in the database:

import { TransactionBaseService } from "@medusajs/medusa"
import { Referral } from "models/referral"
import { EntityManager } from "typeorm"
import ReferralRepository from "../repositories/referral"

// Existing code here

class ReferralService extends TransactionBaseService {
    // Constructor here

    async create(referral: CreateReferralInput): Promise<Referral> {
        return await this.atomicPhase_(async (manager) => {
            const referralRepository = manager.withRepository(
                this.referralRepository_
            )
            const created = referralRepository.create(referral);
            const result = await referralRepository.save(created);

            return result;
        })
    }

}

export default ReferralService

The asynchronous create method creates and saves a Referral entity. It follows an atomic and transactional approach to ensure data integrity.

Within the function, the atomicPhase_ method is used to encapsulate the operations, ensuring that they are executed atomically. The method takes a callback function with a manager parameter, which represents the TypeORM EntityManager instance responsible for managing database transactions.

Inside the callback function, a referralRepository variable is created by associating the this.referralRepository_ with the current transaction using the manager.withRepository() method. This ensures that the repository operations are performed within the transaction context.

The function then proceeds to create a new Referral entity by invoking referralRepository.create(referral). This prepares the entity with the provided data.

Next, the created entity is passed to referralRepository.save(created) to persist in the database. This triggers the actual database operation to insert the entity. Finally, the function returns a promise that resolves to the result of the referralRepository.save() operation, which is the saved Referral object.

Create a Custom Input Type for Referral

The create method takes a referral argument of CreateReferralInput type which hasn't been created yet. Create a referral.ts file in the src/types directory to store this custom input type:

import { Customer } from "@medusajs/medusa/dist/models/customer"

export type CreateReferralInput = {
    referral_code?: string
    referrer_customer?: Customer
}

The CreateReferralInput type defines the structure of the input object when creating a referral. It includes two optional properties: referral_code (representing the referral code) and referrer_customer (representing the referrer customer associated with the referral).

The final updated referral service looks like the below:

import { TransactionBaseService } from "@medusajs/medusa"
import { Referral } from "models/referral"
import { EntityManager } from "typeorm"
import { CreateReferralInput } from "types/referral"
import ReferralRepository from "../repositories/referral"

type InjectedDependencies = {
    manager: EntityManager
    referralRepository: typeof ReferralRepository
}

class ReferralService extends TransactionBaseService {
    protected readonly referralRepository_: typeof ReferralRepository

    constructor({
        referralRepository
    }: InjectedDependencies) {
        super(arguments[0])
        this.referralRepository_ = referralRepository
    }

    async create(referral: CreateReferralInput): Promise<Referral> {
        return await this.atomicPhase_(async (manager) => {
            const referralRepository = manager.withRepository(
                this.referralRepository_
            )
            const created = referralRepository.create(referral);
            const result = await referralRepository.save(created);

            return result;
        })
    }

}

export default ReferralService

Create a Subscriber for Referral Code Creation

You can utilize the event-driven architecture of Medusa to implement the functionality of generating a new referral code for each customer upon registration. When a customer registers on the application, the customer.created event is triggered. This event serves as a trigger point for executing custom logic. In this case, you would create a subscriber that is responsible for handling this event and generating a new referral code for the customer.

Learn how you can create a subscriber in Medusa in their official documentation.

Create a newReferralCode.ts file in the src/subscribers directory and add the following code:

import { CustomerService, EventBusService } from "@medusajs/medusa";
import ReferralService from "../services/referral";

type InjectedProperties = {
    eventBusService: EventBusService
    referralService: ReferralService
    customerService: CustomerService
}

class NewReferralCodeSubscriber {
    private referralService: ReferralService
    private customerService: CustomerService

    constructor(properties: InjectedProperties) {
        this.referralService = properties.referralService;
        this.customerService = properties.customerService;
        properties.eventBusService.subscribe("customer.created", this.assignNewReferralCode);
    }

    assignNewReferralCode = async (customer) => {

        // Generate new referral code
        let newReferralCode = this.generateReferralCode(6);

        await this.referralService.create({
            referral_code: newReferralCode,
            referrer_customer: customer
        })

        await this.customerService.update(customer.id, {
            metadata: { "referral_code": newReferralCode }
        })
    }

    generateReferralCode = (length) => {
        const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        const codeLength = length || 6;
        let referralCode = '';

        while (referralCode.length < codeLength) {
            const randomIndex = Math.floor(Math.random() * characters.length);
            const randomCharacter = characters.charAt(randomIndex);
            referralCode += randomCharacter;
        }

        return referralCode;
    }
}

export default NewReferralCodeSubscriber;

The NewReferralCodeSubscriber relies on several injected services: EventBusService from Medusa, ReferralService, and CustomerService. Upon instantiation, the NewReferralCodeSubscriber class assigns the injected services to their private properties. It subscribes to the customer.created event using the eventBusService.subscribe method and provides the assignNewReferralCode method as the event handler.

The assignNewReferralCode method is an asynchronous function that receives the customer object as its parameter. It generates a new referral code by invoking the generateReferralCode method. The generated code is then used to create a new referral entry through the referralService.create method, which associates the referral code with the customer.

The generateReferralCode method is a utility function used to generate a random alphanumeric code. It takes an optional length parameter to determine the length of the referral code. It iteratively selects random characters from a predefined character set, concatenating them to form the referral code.

Additionally, the handler also updates the customer's metadata with the newly generated referral code using the customerService.update method.

Setting up the Medusa Storefront

The next task is to let the users enter a referral code while registering on the application. To implement this, you'd need to set up the Medusa Storefront. In this tutorial, you will set up the Next.js storefront offered by Medusa.

To begin, create a new Next.js project using the Medusa starter template by running the following command:

npx create-next-app -e https://github.com/medusajs/nextjs-starter-medusa my-medusa-storefront

After the project is created, navigate to the newly created directory and rename the template environment variable file to use environment variables in development:

cd my-medusa-storefront
mv .env.template .env.local

Ensure that the Medusa server is running, and then start the storefront by running the following command:

npm run dev

This command will start the development server for the Medusa storefront on localhost:8000, allowing you to test and interact with the application locally.

Add Referral Code Field in Registration Form

Now that your storefront is set up successfully, it's time to add the referral code field to the registration form.

Open the src/modules/account/components/register/index.tsx file and add a referral_code property in the RegisterCredentials:

interface RegisterCredentials extends FieldValues {
  ...
  referral_code?: string
}

Next, in the Register component, add the input field for the referral code after the password input field:

<Input
  label="Have a referral code?"
  {...register("referral_code")}
  autoComplete="off"
  errors={errors}
/>

At this point, the register page should look like the below:

Updated Register Page

The customers.create method doesn't accept the referral code by default. Thus, before passing the credentials to the method, you'd need to extract the referral code from it as below:

const onSubmit = handleSubmit(async (credentials) => {
    const { referral_code, ...registerCredentials } = credentials

    await medusaClient.customers
      .create(registerCredentials)
      .then(() => {
        refetchCustomer()
        router.push("/account")
      })
      .catch(handleError)
  })

In the above code, the credentials object is destructured to extract the referral_code value and the remaining registerCredentials. Then, you pass the registerCredentials to the create method.

Implement the Referral Code Validation API

When the user enters a referral code, you must check whether the referral code entered by the user is valid and associated with any customer. To do this, you'd create an API endpoint in the backend, and make a call from the frontend.

Open the src/api/index.ts file and add the following code:

import { Router } from "express"

export default (rootDirectory: string): Router | Router[] => {

  const router = Router()

  // add your custom routes here

  router.get("/store/referral/:referralCode",
    async (req, res) => {
      try {
        const referralService = req.scope.resolve("referralService");

        const referralCode = req.params.referralCode;

        const referral = await referralService.getByCode(referralCode);

        if (referral) {
          res.json(referral);
        } else {
          res.status(404).json({ error: "Referral not found" })
        }
      } catch (error) {
        res.status(500).json({ error: "Internal server error" });
      }
    }
  )

  return router;
}

In the above code, you create a GET endpoint at /store/referral/:referralCode . This endpoint expects a referral code as a parameter in the URL.

The referralService is used to retrieve the referral object corresponding to the given referral code using the getByCode method. If a referral is found, it is returned as a JSON response using res.json(referral). Otherwise, if no referral is found, a 404 status code with an error message is sent as the response.

In case of any errors during the execution of the callback or the retrieval process, a generic error message with a 500 status code is returned.

Learn more about creating an endpoint in Medusa in their official documentation.

CORS Configuration

Since you'll be using the storefront to make a request on the above-created endpoint, you need to pass your endpoints Cross-Origin Resource Origin (CORS) options using the cors package.

  1. Begin by importing the necessary utility functions and types from Medusa's packages along with the cors library:

     import { getConfigFile, parseCorsOrigins } from "medusa-core-utils";
     import { ConfigModule } from "@medusajs/medusa/dist/types/global";
     import cors from "cors";
    
  2. In the exported function, retrieve the CORS configurations of your backend using the utility functions you imported:

     export default (rootDirectory: string): Router | Router[] => {
       const { configModule } =
         getConfigFile<ConfigModule>(rootDirectory, "medusa-config")
       const { projectConfig } = configModule
    
       // ...
     }
    
  3. Create an object to hold the CORS configurations. Since it's a storefront endpoint, pass the origins specified in the store_cors property of the project configuration:

     const corsOptions = {
       origin: projectConfig.store_cors.split(","),
       credentials: true,
     };
    
  4. For the route you added, create an OPTIONS request and add cors as middleware for the route, passing it the CORS options:

     router.options("/store/referral/:referralCode", cors(corsOptions))
     router.get("/store/referral/:referralCode", cors(corsOptions),
       async (req, res) => {
         // ...
     });
    

The getByCode Service Method

If you noticed above, you have used a getByCode method from the referral service. But you haven't defined it yet. Open the src/services/referral.ts file and add the following method to the ReferralService class:

class ReferralService extends TransactionBaseService {
    ...

    async getByCode(referralCode: string): Promise<Referral> {
        return await this.atomicPhase_(async (manager) => {
            const referralRepository = manager.withRepository(
                this.referralRepository_
            )
            const referral = await referralRepository
                .createQueryBuilder("referral")
                .leftJoinAndSelect("referral.referrer_customer", "referrer_customer")
                .where("referral.referral_code = :referralCode", { referralCode })
                .getOne();

            return referral;
        })
    }
}

export default ReferralService

The above code defines an async function named getByCode that retrieves a referral based on a given referral code.

A query builder is utilized to construct a query for retrieving the referral. It performs a left join with the referrer_customer entity, allowing the referral's associated customer data to be included in the result.

The query filters the referrals based on the provided referralCode using the where clause. It retrieves only one referral using the getOne method, indicating that the referral code is expected to be unique.

Once the referral is retrieved, it is returned as the result of the getByCode function.

Using the API To Validate the Referral Code

Now that you have created the API endpoint to validate the referral code, you'd need to use it in the storefront. Open the file and update the onSubmit function as below:

const onSubmit = handleSubmit(async (credentials) => {
  let referrerCustomerId: string = ""
  const { referral_code, ...registerCredentials } = credentials
  if (referral_code) {
    referrerCustomerId = await getReferrerCustomerId(referral_code)
    if (!referrerCustomerId) {
      return
    }
  }

  await medusaClient.customers
    .create(registerCredentials)
    .then(() => {
      refetchCustomer()
      router.push("/account")
    })
    .catch(handleError)
})

The above code uses a getReferrerCustomerId function to get the referrer customer's ID. If no referrer's customer ID is found, the function returns early.

Next, create the getReferrerCustomerId function where you send a GET request on the newly created API endpoint:

  const [referralError, setReferralError] = useState<string | undefined>(undefined)

  const getReferrerCustomerId = async (referralCode: string) => {
    try {
      const response = await fetch(
        `http://localhost:9000/store/referral/${referralCode}`
      )

      if (!response.ok) {
        setReferralError("The referral code is invalid. Please check.")
        return null
      }
      const referral = await response.json()

      return referral.referrer_customer.id
    } catch (error) {
      setReferralError(
        "An error occurred while validating the referral code. Please try again."
      )
      return null
    }
  }

In the above code, you first initialize a state variable for the referral error that may occur while handling the API request.

Next, the getReferrerCustomerId function is an asynchronous function that takes a referralCode parameter. Within the function, it attempts to make a GET request on the API endpoint with the referral code as a parameter.

If the response received from the server indicates an error (not a 2xx status code), the setReferralError function is called to update the referralError state with an appropriate error message. The function then returns null to indicate an invalid referral code.

If the response is successful, the function parses the response body as JSON using response.json(). It assumes that the response body contains the referral object. The function then returns the ID of the referrer's customer extracted from the parsed referral object.

If any error occurs during the HTTP request or JSON parsing, the catch block is executed. It sets the referralError state to an error message indicating that an error occurred while validating the referral code. The function returns null to indicate the validation failure.

You can use the referralError state variable in the Register component to display the referral-related errors as below:

{referralError && (
  <div>
    <span className="text-rose-500 w-full text-small-regular">
      {referralError}
    </span>
  </div>
)}

Display the Customer’s Referral Code

Ideally, a customer should be able to view its referral code and share it with others. But as of now, the customer cannot view it.

Open the src/modules/account/components/overview/index.tsx file in the storefront and add the following code:

<div className="flex flex-col gap-y-4">
  <div className="flex items-center gap-x-2">
    <h3 className="text-large-semi">Referral Details</h3>
  </div>
  <p>
    Your referral code is{" "}
    <strong>{customer?.metadata["referral_code"] || "loading..."}</strong>.
   </p>
</div>

If you remember, in one of the previous sections, you had stored the referral_code in the customer's metdata. In the above code, you used the customer's metadata to get the value of referral_code key.

Demo: Using Referal Code While Registration

In the above demo, the process begins with a regular user signing up for an account without using a referral code. Once the registration is complete, the customer gains access to their profile where they can find their unique referral code and share it.

Following that, another user attempts to register for an account. Initially, this user enters an invalid referral code, resulting in an error message. However, upon entering a valid referral code, the registration process is successful, and the user can now view their own referral code within their account.

In the upcoming sections, you'll implement the discounting logic for the referrer and referred customers.

Implementing Discounts for the Referrer and Referred Customers

Implementing discounts for the referrer and referred customers involves setting up a system where customers who refer others receive discounts, and the referred customers also receive their own discounts.

Here's a breakdown of the approach you're going to follow:

  1. Frontend Implementation:

    • When a user enters a referral code during registration, the frontend will send a request to the /store/referral/:referralCode API endpoint to retrieve the referrer customer's ID.

    • After the account is created, the frontend will obtain the referred customer's ID.

  2. Backend Implementation:

    • You will create a new API endpoint /store/referral/discounts that accepts the referrer and referred customer IDs as a POST request.

    • This endpoint will trigger two events internally: referrer-discount.update and referred-discount.update.

    • In the backend, you will create two subscribers for these events, which will add the referrer and referred customers to their respective customer groups: "Referrer Customers" and "Referred Customers".

  3. Medusa Admin Configuration:

    • Using the Medusa Admin, you can configure the appropriate discounts for the referrer and referred customers. These discounts can be applied to their respective customer groups.
  4. Tracking Referral Count:

    • You will maintain a total_referrals key in the customer's metadata. This key will represent the total number of customers who have successfully used a customer's referral code.

    • Whenever a customer uses the discount referral discount applicable to it, the total_referrals count will decrease by 1.

    • Similarly, when a customer signs up using a referral code, the total_referrals count will increase by 1.

Frontend Implementation

To implement the functionality in the front end, as previously mentioned, the first step is to retrieve the IDs of the referred and referrer customers. Once obtained, you can proceed with making a POST request to the designated API endpoint. This section will guide you through the necessary steps to implement this process in the front end of your application.

Open the src/modules/account/components/register/index.tsx and update the onSubmit method as below:

const onSubmit = handleSubmit(async (credentials) => {
    let referrerCustomerId: string = ""
    const { referral_code, ...registerCredentials } = credentials
    if (referral_code) {
      referrerCustomerId = await getReferrerCustomerId(referral_code)
      if (!referrerCustomerId) {
        return
      }
    }

    let referredCustomerId: string = ""

    await medusaClient.customers
      .create(registerCredentials)
      .then(({ customer }) => {
        referredCustomerId = customer.id
        refetchCustomer()
        router.push("/")
        updateDiscounts(referredCustomerId, referrerCustomerId)
      })
      .catch(handleError)
  })

In the above code, you declare a referredCustomerId variable and initialize it as an empty string. If the customer creation is successful, the then() block is executed, and the customer object is destructured from the response as { customer }. The referredCustomerId is assigned the value of the newly created customer's ID.

Finally, you invoke the updateDiscounts(referredCustomerId, referrerCustomerId) function to initiate updating discounts for the referred and referrer customers, passing their respective IDs.

const updateDiscounts = async (
    referredCustomerId: string,
    referrerCustomerId: string
  ) => {
    try {
      const response = await fetch(
        "http://localhost:9000/store/referral/discounts",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ referredCustomerId, referrerCustomerId }),
        }
      )

      if (response.ok) {
        const responseData = await response.json()
        console.log(responseData) // Handle the response data as needed
      } else {
        const errorData = await response.json()
        console.error(errorData) // Handle the error response
      }
    } catch (error) {
      console.error("An error occurred while updating discounts", error) // Handle any other errors
    }
  }

The above code defines an asynchronous function called updateDiscounts, which is responsible for making a POST request to update discounts for the referred and referrer customers.

The function uses the fetch() function to send a POST request to the /store/referral/discounts endpoint. The request includes the referredCustomerId and referrerCustomerId values in the request body as a JSON payload.

Backend Implementation

Let's create the /store/referral/discounts API endpoint in the backend. Open the src/api/index.ts file and add the following code:

const bodyParser = require("body-parser")

export default (rootDirectory: string): Router | Router[] => {

  // ...

  const router = Router()
  router.use(bodyParser.json());

  // add your custom routes here

  // ...

  router.options("/store/referral/discounts", cors(corsOptions))
  router.post("/store/referral/discounts", cors(corsOptions),
    async (req, res) => {
      try {
        const eventBusService = req.scope.resolve("eventBusService");
        const customerService = req.scope.resolve("customerService");

        const { referredCustomerId, referrerCustomerId } = req.body;

        if (referrerCustomerId) {
          eventBusService.emit("referred-discount.update", { referredCustomerId });
          eventBusService.emit("referrer-discount.update", { referrerCustomerId });
          const referrerCustomer = await customerService.retrieve(referrerCustomerId)

          const totalReferrals = referrerCustomer.metadata
            && typeof referrerCustomer.metadata.total_referrals === "number"
            ? referrerCustomer.metadata.total_referrals
            : 0;

          await customerService.update(referrerCustomerId, {
            metadata: {
              "total_referrals": totalReferrals + 1
            }
          })
        }
        res.json({ success: true });
      } catch (error) {
        console.log(error);

        res.status(500).json({ error: "Internal server error" });
      }
    }
  )

  return router;
}

The above code defines an API endpoint that handles updating discounts for referred and referrer customers. When a POST request is made to the /store/referral/discounts endpoint, the code retrieves the required services and data from the request.

Since you're creating a POST endpoint, you'd need to import the body-parser library and use it.

If a referrer customer ID is provided, the code emits events to update the discounts for both the referred and referrer customers. It also retrieves the referrer customer's metadata, increments the total referral count, and updates the metadata accordingly. This condition check is important because you don't want to emit the events if the customer hasn't been referred by other customers.

Finally, a success response is sent back to the client. If any errors occur, an error response is returned. Overall, this code facilitates the process of updating discounts and tracking the number of referrals for customers.

Subscribers Implementation

Now, you need two subscribers that listen to the events emitted by the API endpoint. The subscribers add the customers to their respective customer groups.

The "Referrer Customers" Customer Group

Create a referrerCustomerGroupAssignment.ts file in the src/subscribers directory and add the following code:

import { CustomerGroupService, EventBusService } from "@medusajs/medusa"


type InjectedProperties = {
    eventBusService: EventBusService
    customerGroupService: CustomerGroupService
}


class ReferrerCustomerGroupAssignmentSubscriber {
    private customerGroupService: CustomerGroupService
    private customerGroupName: string

    constructor(properties: InjectedProperties) {
        this.customerGroupService = properties.customerGroupService;
        this.customerGroupName = "Referrer Customers";
        properties.eventBusService.subscribe("referrer-discount.update", this.handleGroupAssignment);
    }

    handleGroupAssignment = async ({ referrerCustomerId }) => {

        let customerGroup;

        // Check if "Referrer Customers" customer group exists
        let customerGroupList = await this.customerGroupService.list({ name: this.customerGroupName }, { take: 1 });

        // If it doesn't exist, create it
        if (!customerGroupList.length) {
            customerGroup = await this.customerGroupService.create({ name: this.customerGroupName });
        } else {
            customerGroup = customerGroupList[0];
        }

        customerGroup = await this.customerGroupService.retrieve(customerGroup.id, { relations: ["customers"] })

        const isCustomerInGroup = customerGroup.customers.some(
            (customer) => customer.id === referrerCustomerId
        );

        if (isCustomerInGroup) return;

        // Add customer to "Referrer Customers" customer group
        await this.customerGroupService.addCustomers(customerGroup.id, [referrerCustomerId]);
    }
}

export default ReferrerCustomerGroupAssignmentSubscriber;

The above code defines a class called ReferrerCustomerGroupAssignmentSubscriber. This class is responsible for assigning customers to the "Referrer Customers" customer group when a referral discount is updated.

The class has a constructor that receives InjectedProperties as input, which includes the eventBusService and customerGroupService from the Medusa library. In the constructor, the eventBusService is used to subscribe to the event referrer-discount.update and associate it with the handleGroupAssignment method.

The handleGroupAssignment method is triggered when the referrer-discount.update event is emitted. It retrieves the referrerCustomerId from the event payload.

The code then checks if the "Referrer Customers" customer group already exists by listing customer groups with the specified name. If it doesn't exist, a new customer group is created using the customerGroupService.

Next, the code retrieves the customer group and its associated customers. It checks if the referrerCustomerId is already present in the customer group. If the customer is already in the group, the method returns early. If the customer is not in the group, the referrerCustomerId is added to the "Referrer Customers" customer group using the customerGroupService.

The "Referred Customers" Customer Group

Create a referredCustomerGroupAssignment.ts file in the src/subscribers directory and add the following code:

import { CustomerGroupService, EventBusService } from "@medusajs/medusa"


type InjectedProperties = {
    eventBusService: EventBusService
    customerGroupService: CustomerGroupService
}


class ReferredCustomerGroupAssignmentSubscriber {
    private customerGroupService: CustomerGroupService
    private customerGroupName: string

    constructor(properties: InjectedProperties) {
        this.customerGroupService = properties.customerGroupService;
        this.customerGroupName = "Referred Customers";
        properties.eventBusService.subscribe("referred-discount.update", this.handleGroupAssignment);
    }

    handleGroupAssignment = async ({ referredCustomerId }) => {

        let customerGroup;

        // Check if "Referred Customers" customer group exists
        let customerGroupList = await this.customerGroupService.list({ name: this.customerGroupName }, { take: 1 });

        // If it doesn't exist, create it
        if (!customerGroupList.length) {
            customerGroup = await this.customerGroupService.create({ name: this.customerGroupName });
        } else {
            customerGroup = customerGroupList[0];
        }

        // Add customer to "Referred Customers" customer group
        await this.customerGroupService.addCustomers(customerGroup.id, [referredCustomerId]);
    }
}

export default ReferredCustomerGroupAssignmentSubscriber;

The above code defines a class called ReferredCustomerGroupAssignmentSubscriber. This class is responsible for assigning customers to the "Referred Customers" customer group when a referred customer discount is updated.

The InjectedProperties type specifies the injected dependencies for the class, which include the eventBusService and customerGroupService.

The class has a constructor that receives the InjectedProperties as input. It assigns the customerGroupService and sets the customerGroupName to "Referred Customers". It then subscribes to the referred-discount.update event using the eventBusService and associates it with the handleGroupAssignment method.

The handleGroupAssignment method is triggered when the referred-discount.update event is emitted. It retrieves the referredCustomerId from the event payload.

The code then checks if the "Referred Customers" customer group already exists by listing customer groups with the specified name. If it doesn't exist, a new customer group is created using the customerGroupService.

Finally, the referredCustomerId is added to the "Referred Customers" customer group using the customerGroupService.

Setting up the Medusa Admin

You'll require the Medusa Admin to set up the coupon code discounts for the customer groups. In this section, you'll set up the Medusa Admin Dashboard.

To install the Medusa Admin Dashboard in your Medusa backend directory, run the following command:

npm install @medusajs/admin

Next, open the medusa-config.js file in your Medusa backend directory. Inside the plugins array, add the following configuration for the Admin plugin:

const plugins = [
  // ...
  {
    resolve: "@medusajs/admin",
    /** @type {import('@medusajs/admin').PluginOptions} */
    options: {
      // ...
    },
  },
]

Check out the different options accepted by the admin plugin.

Test the admin dashboard by running the following command in the directory of the Medusa backend:

medusa develop

The Admin Dashboard will be available on the URL localhost:9000/app . Since you've already seeded the data, you can use the email admin@medusa-test.com and password supersecret to log in.

Creating Discounts using Admin Dashboard

In this section, you'll create two customer groups - Referrer Customers and Referred Customers, using the Admin Dashboard. Next, you'll create a discount coupon code for each of the customer groups.

Follow the video to see how you can do it:

In the video, the following two referral discount coupon codes are created:

  1. NEW10 - 10% discount applicable for the new customers who used a valid referral code during registration

  2. REFER20 - 20% discount applicable for customers whose referral code was used during registration

Tracking Referral Discounts

Since you maintain a total_referrals count in the customer's metadata, you would need to update it once the user applies the "REFER20" or "NEW10" coupon code and places an order.

To update the total_referrals count and manage customer groups based on coupon codes, you can implement the following logic:

  1. For referred customers:

    • If they apply the "NEW10" coupon code, remove the customer from the "Referred Customers" customer group.
  2. For referrer customers:

    • If they apply the "REFER20" coupon code, reduce the total_referrals count by 1.

    • If the total_referrals count was previously 1, set it to 0, and remove the customer from the "Referrer Customers" customer group.

When a customer places an order, the order.placed event is emitted. You can create a subscriber that listens to the event and implements the above logic.

Create a discount.ts file in the src/subscribers directory and add the following code:

import { CustomerGroupService, CustomerService, EventBusService, OrderService } from "@medusajs/medusa"


type InjectedProperties = {
    eventBusService: EventBusService
    customerGroupService: CustomerGroupService
    customerService: CustomerService
    orderService: OrderService
}


class DiscountSubscriber {
    private customerGroupService: CustomerGroupService
    private customerService: CustomerService
    private orderService: OrderService
    private referredCustomerGroupName: string
    private referrerCustomerGroupName: string

    constructor(properties: InjectedProperties) {
        this.customerGroupService = properties.customerGroupService;
        this.customerService = properties.customerService;
        this.orderService = properties.orderService;
        this.referredCustomerGroupName = "Referred Customers";
        this.referrerCustomerGroupName = "Referrer Customers";

        properties.eventBusService.subscribe("order.placed", this.handleDiscounts);
    }

    handleDiscounts = async ({ id }) => {

        // Retrieve the order by id
        const order = await this.orderService.retrieve(id, { relations: ["discounts"] });

        // Get the code used by the customer
        const discountCodeUsed = order.discounts && order.discounts[0].code;

        // Get the customer
        const customer = await this.customerService.retrieve(order.customer_id, { relations: ["groups"] });

        if (discountCodeUsed === "NEW10") {
            const customerGroup = customer.groups.find(group => group.name === this.referredCustomerGroupName);
            await this.customerGroupService.removeCustomer(customerGroup.id, [customer.id]);
        } else if (discountCodeUsed === "REFER20") {
            const totalReferrals = customer.metadata.total_referrals as number;

            if (totalReferrals == 1) {
                await this.customerService.update(customer.id, {
                    metadata: { "total_referrals": 0 }
                })
                const customerGroup = customer.groups.find(group => group.name === this.referrerCustomerGroupName);
                await this.customerGroupService.removeCustomer(customerGroup.id, [customer.id]);
            } else if (totalReferrals > 1) {
                await this.customerService.update(customer.id, {
                    metadata: { "total_referrals": totalReferrals - 1 }
                })
            }
        }
    }
}

export default DiscountSubscriber;

The above code defines a DiscountSubscriber class that listens for the order.placed event through the eventBusService and associates the handleDiscounts method.

In the handleDiscounts method, the code retrieves the order details and extracts the discount code used by the customer. By utilizing the customerService, it retrieves the customer information associated with the order. The code then proceeds to evaluate the discount code used.

If the discount code is identified as "NEW10", it means the customer applied this code. Consequently, the customer is removed from the "Referred Customers" group using the customerGroupService. This step ensures that referred customers no longer remain in the group once they have used the "NEW10" discount.

On the other hand, if the discount code is recognized as "REFER20", the code accesses the customer's metadata to obtain the total_referrals count. If the count is equal to 1, it implies that the customer had only one referral. In this scenario, the code updates the customer's metadata to set the total_referrals count to 0. Additionally, the customer is removed from the "Referrer Customers" group.

However, if the total_referrals count is greater than 1, the code simply decreases the count by 1 in the customer's metadata, representing the utilization of one referral while maintaining the customer's presence in the "Referrer Customers" group.

You'd also want the customers to see their total number of referrals. Open the src/modules/account/components/overview/index.tsx file in the storefront and add the following code:

<div className="flex flex-col gap-y-4">
  <div className="flex items-center gap-x-2">
    <h3 className="text-large-semi">Referral Details</h3>
  </div>
  <p>
    Your referral code is{" "}
    <strong>{customer?.metadata["referral_code"] || "loading..."}</strong>
    . You have referred{" "}
    <strong>{customer?.metadata["total_referrals"] || 0}</strong>{" "}
    customers till now.
  </p>
</div>

Demo: Using Referral Discounts

Wrapping Up

In this tutorial, you have covered the step-by-step process of implementing a referral system in Medusa. With a functional referral system in place, you can leverage the power of customer referrals to expand your customer base and drive business growth.

This implementation was possible because of Medusa’s composable architecture. You can learn more about Medusa through its documentation. Here are some documents that you can start with:

Should you have any issues or questions related to Medusa, then feel free to reach out to the Medusa team via Discord.

Did you find this article valuable?

Support Ashutosh Writes by becoming a sponsor. Any amount is appreciated!