Generate Invoices for Orders in Medusa

In this tutorial, you will learn how to generate invoices for orders in your Medusa application.

When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. The Medusa application's commerce features are built around Commerce Modules, which are available out-of-the-box. These features include order management capabilities.

You can extend the Medusa application to automatically generate invoices for orders, manage invoice configurations through the admin dashboard, and provide customers with easy access to their invoices through the storefront.

Summary#

By following this tutorial, you will learn how to:

  • Install and set up Medusa.
  • Store default invoice configurations and manage them from the Medusa Admin dashboard.
  • Generate PDF invoices for orders, and allow admin users and customers to download them.
  • Send PDF invoices as attachments in order confirmation emails.
  • Mark previously generated invoices as stale when orders are updated.

You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer.

Diagram illustrating the flow from the customer placing the order, them receiving the invoice, and the admin downloading the invoice

Full Code
Find the full code for this tutorial.
OpenAPI Specs for Postman
Import this OpenAPI Specs file into tools like Postman.

Step 1: Install a Medusa Application#

Start by installing the Medusa application on your machine with the following command:

Terminal
npx create-medusa-app@latest

You'll first be asked for the project's name. Then, when asked whether you want to install the Next.js Starter Storefront, choose Yes.

Afterward, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a separate directory with the {project-name}-storefront name.

Why is the storefront installed separately? The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called API routes. Learn more in Medusa's Architecture documentation.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterward, you can log in with the new user and explore the dashboard.

Ran into Errors? Check out the troubleshooting guides for help.

Step 2: Create Invoice Generator Module#

In Medusa, you can build custom features in a module. A module is a reusable package with the data models and functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup.

In this step, you'll build an Invoice Generator Module that defines the data models and logic to manage invoices. Later, you'll build commerce flows related to invoices around the module.

Note: Refer to the Modules documentation to learn more.

a. Create Module Directory#

Create the directory src/modules/invoice-generator that will hold the Invoice Generator Module's code.

b. Create Data Models#

A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations.

Note: Refer to the Data Models documentation to learn more.

For the Invoice Generator Module, you'll create a data model to store default invoice configurations and another to store generated invoices.

InvoiceConfig Data Model

To create the first data model, create the file src/modules/invoice-generator/models/invoice-config.ts with the following content:

src/modules/invoice-generator/models/invoice-config.ts
1import { model } from "@medusajs/framework/utils"2
3export const InvoiceConfig = model.define("invoice_config", {4  id: model.id().primaryKey(),5  company_name: model.text(),6  company_address: model.text(),7  company_phone: model.text(),8  company_email: model.text(),9  company_logo: model.text().nullable(),10  notes: model.text().nullable(),11})

The InvoiceConfig data model has the following properties:

  • id: The primary key of the table.
  • company_name: The name of the company issuing the invoice.
  • company_address: The address of the company.
  • company_phone: The phone number of the company.
  • company_email: The email address of the company.
  • company_logo: The URL of the company logo image.
  • notes: Additional notes to include in the invoice.

You can also add other fields as needed, such as tax information or payment terms.

Note: Learn more about defining data model properties in the Property Types documentation.

Invoice Data Model

Next, you'll create the Invoice data model that represents a generated invoice.

Create the file src/modules/invoice-generator/models/invoice.ts with the following content:

src/modules/invoice-generator/models/invoice.ts
1import { model } from "@medusajs/framework/utils"2
3export enum InvoiceStatus {4  LATEST = "latest",5  STALE = "stale",6}7
8export const Invoice = model.define("invoice", {9  id: model.id().primaryKey(),10  display_id: model.autoincrement(),11  order_id: model.text(),12  status: model.enum(InvoiceStatus).default(InvoiceStatus.LATEST),13  pdfContent: model.json(),14})

The Invoice data model has the following properties:

  • id: The primary key of the table.
  • display_id: An auto-incrementing identifier for the invoice, which will be used to display the invoice number.
  • order_id: The ID of the order that the invoice belongs to.
  • status: The current status of the invoice.
    • latest indicates the invoice is the most recent version for the order.
    • stale indicates a previous invoice for the order that became stale after order updates.
  • pdfContent: The content of the invoice in object format that works with pdfmake. You'll use this later to generate the invoice PDF.

c. Create Module's Service#

You can manage your module's data models in a service.

A service is a TypeScript class that the module exports. In the service's methods, you can connect to the database, allowing you to manage your data models, or connect to a third-party service, which is useful if you're integrating with external services.

Note: Refer to the Module Service documentation to learn more.

To create the Invoice Generator Module's service, create the file src/modules/invoice-generator/service.ts with the following content:

src/modules/invoice-generator/service.ts
1import { MedusaService } from "@medusajs/framework/utils"2import { InvoiceConfig } from "./models/invoice-config"3import { Invoice } from "./models/invoice"4
5class InvoiceGeneratorService extends MedusaService({6  InvoiceConfig,7  Invoice,8}) { }9
10export default InvoiceGeneratorService

The InvoiceGeneratorService extends MedusaService, which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods.

So, the InvoiceGeneratorService class now has methods like createInvoices and retrieveInvoice.

Note: Find all methods generated by the MedusaService in the Service Factory reference.

d. Export Module Definition#

The final piece to a module is its definition, which you export in an index.ts file at its root directory. This definition tells Medusa the name of the module and its service.

So, create the file src/modules/invoice-generator/index.ts with the following content:

src/modules/invoice-generator/index.ts
1import InvoiceModuleService from "./service"2import { Module } from "@medusajs/framework/utils"3
4export const INVOICE_MODULE = "invoiceGenerator"5
6export default Module(INVOICE_MODULE, {7  service: InvoiceModuleService,8})

You use the Module function to create the module's definition. It accepts two parameters:

  1. The module's unique name.
  2. An object whose service property is the module's service class.

e. Register the Module#

Once you finish building the module, add it to Medusa's configurations to start using it.

In medusa-config.ts, add a modules property and pass an array with your custom module:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    {5      resolve: "./src/modules/invoice-generator",6    },7  ],8})

Each object in the modules array has a resolve property, whose value is either a path to the module's directory, or an npm package’s name.

f. Generate and Run Migrations#

Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript class that defines database changes made by a module.

Note: Refer to the Migrations documentation to learn more.

Medusa's CLI tool can generate the migrations for you. To generate a migration for the Invoice Generator Module, run the following command in your Medusa application's directory:

Terminal
npx medusa db:generate invoice-generator

The db:generate command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a migrations directory under src/modules/invoice-generator that holds the generated migration.

Then, to reflect these migrations on the database, run the following command:

Terminal
npx medusa db:migrate

The tables for the InvoiceConfig and Invoice data models are now created in the database.


Step 3: Create Default Invoice Configurations#

In this step, you'll create default invoice configurations that admins can later manage through the Medusa Admin dashboard.

Since you need to create the default configurations when the Medusa application starts, you'll use a loader. A loader is a script in your module that Medusa runs on application startup.

a. Create Loader#

To create the loader, create the file src/modules/invoice-generator/loaders/create-default-config.ts with the following content:

src/modules/invoice-generator/loaders/create-default-config.ts
1import {2  LoaderOptions,3  IMedusaInternalService,4} from "@medusajs/framework/types"5import { InvoiceConfig } from "../models/invoice-config"6
7export default async function createDefaultConfigLoader({8  container,9}: LoaderOptions) {10  const service: IMedusaInternalService<11    typeof InvoiceConfig12  > = container.resolve("invoiceConfigService")13
14  const [_, count] = await service.listAndCount()15
16  if (count > 0) {17    return18  }19
20  await service.create({21    company_name: "Acme",22    company_address: "123 Acme St, Springfield, USA",23    company_phone: "+1 234 567 8900",24    company_email: "admin@example.com",25  })26}

The loader function accepts an object having the module's container as a parameter. You can use this container to resolve Framework tools and the module's resources.

In the loader, you resolve a service that Medusa generates for the InvoiceConfig data model. You use that service to create default configurations if none exist.

b. Register the Loader#

Next, to register the loader in the module's definition, update the Module function usage in src/modules/invoice-generator/index.ts:

src/modules/invoice-generator/index.ts
1// other imports...2import createDefaultConfigLoader from "./loaders/create-default-config"3
4// ...5
6export default Module(INVOICE_MODULE, {7  // ...8  loaders: [createDefaultConfigLoader],9})

You pass a loaders property to the Module function, whose value is an array of loader functions.

Note: Refer to the Loaders documentation to learn more about loaders.

c. Run the Loader#

To run the loader, start the Medusa application:

The Medusa application will run your loader to create the default invoice configurations in the database. You'll verify that it worked in the next step after adding the admin settings page.


Step 4: Allow Admins to Manage Invoice Configurations#

In this step, you'll customize the Medusa application to allow admin users to manage the default invoice configurations through the Medusa Admin dashboard.

To build this feature, you need to create:

  1. A workflow with the business logic to manage invoice configurations.
  2. An API route that exposes the invoice configuration management functionality to clients.
  3. A settings page in the Medusa Admin dashboard that allows admin users to manage the invoice configurations.

a. Invoice Configuration Management Workflow#

A workflow is a series of queries and actions, called steps, that complete a task. A workflow is similar to a function, but it allows you to track its executions' progress, define roll-back logic, and configure other advanced features.

Note: Refer to the Workflows documentation to learn more.

The workflow that manages invoice configurations will have a single step that updates the invoice configuration.

Update Invoice Configuration Step

To create a step, create the file src/workflows/steps/update-invoice-config.ts with the following content:

src/workflows/steps/update-invoice-config.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { INVOICE_MODULE } from "../../modules/invoice-generator"3
4type StepInput = {5  id?: string6  company_name?: string7  company_address?: string8  company_phone?: string9  company_email?: string10  company_logo?: string11  notes?: string12}13
14export const updateInvoiceConfigStep = createStep(15  "update-invoice-config",16  async ({ id, ...updateData }: StepInput, { container }) => {17    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)18
19    const prevData = id ? 20      await invoiceGeneratorService.retrieveInvoiceConfig(id) : 21      (await invoiceGeneratorService.listInvoiceConfigs())[0]22
23    const updatedData = await invoiceGeneratorService.updateInvoiceConfigs({24      id: prevData.id,25      ...updateData,26    })27
28    return new StepResponse(updatedData, prevData)29  },30  async (prevInvoiceConfig, { container }) => {31    if (!prevInvoiceConfig) {32      return33    }34
35    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)36
37    await invoiceGeneratorService.updateInvoiceConfigs({38      id: prevInvoiceConfig.id,39      company_name: prevInvoiceConfig.company_name,40      company_address: prevInvoiceConfig.company_address,41      company_phone: prevInvoiceConfig.company_phone,42      company_email: prevInvoiceConfig.company_email,43      company_logo: prevInvoiceConfig.company_logo,44    })45  }46)

You create a step with the createStep function. It accepts three parameters:

  1. The step's unique name.
  2. An async function that receives two parameters:
    • The step's input, which is an object having the data to update in the invoice configuration.
    • An object that has properties including the Medusa container, which is a registry of Framework and commerce tools that you can access in the step.
  3. An async compensation function that undoes the actions performed by the step function. This function is only executed if an error occurs during the workflow's execution.

In the step function, you resolve the Invoice Generator Module's service from the Medusa container. Then, you either retrieve the invoice configuration by its ID, or list all invoice configurations and use the first one.

Next, you update the invoice configuration with the data passed to the step function.

Finally, a step function must return a StepResponse instance. The StepResponse constructor accepts two parameters:

  1. The step's output, which is the updated invoice configuration.
  2. Data to pass to the step's compensation function.

In the compensation function, you undo the invoice configuration updates if an error occurs during the workflow's execution.

Update Invoice Configuration Workflow

Next, you'll create a workflow that uses the updateInvoiceConfigStep step to update the invoice configuration.

Create the file src/workflows/update-invoice-config.ts with the following content:

src/workflows/update-invoice-config.ts
1import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { updateInvoiceConfigStep } from "./steps/update-invoice-config"3
4type WorkflowInput = {5  id?: string6  company_name?: string7  company_address?: string8  company_phone?: string9  company_email?: string10  company_logo?: string11  notes?: string12}13
14export const updateInvoiceConfigWorkflow = createWorkflow(15  "update-invoice-config",16  (input: WorkflowInput) => {17    const invoiceConfig = updateInvoiceConfigStep(input)18
19    return new WorkflowResponse({20      invoice_config: invoiceConfig,21    })22  }23)

You create a workflow using the createWorkflow function. It accepts the workflow's unique name as a first parameter.

It accepts a second parameter: a constructor function that holds the workflow's implementation. The function accepts an input object with the invoice configuration data to update.

In the workflow, you update the invoice configuration using the updateInvoiceConfigStep step.

A workflow must return an instance of WorkflowResponse that accepts the data to return to the workflow's executor.

b. Invoice Configuration API Routes#

Next, you'll create two API routes:

  1. An API route to retrieve the default invoice configurations. This is useful to display the current configurations on the settings page.
  2. An API route to update the default invoice configurations. This is useful to allow admin users to update the configurations from the settings page.

Retrieve Invoice Configurations API Route

An API route is created in a route.ts file under a sub-directory of the src/api directory. The path of the API route is the file's path relative to src/api.

Note: Refer to the API routes to learn more about them.

Create the file src/api/admin/invoice-config/route.ts with the following content:

src/api/admin/invoice-config/route.ts
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"2
3export async function GET(4  req: MedusaRequest,5  res: MedusaResponse6) {7  const query = req.scope.resolve("query")8
9  const { data: [invoiceConfig] } = await query.graph({10    entity: "invoice_config",11    fields: ["*"],12  })13
14  res.json({15    invoice_config: invoiceConfig,16  })17}

Since you export a GET route handler function, you expose a GET API route at /admin/invoice-config.

In the route handler, you resolve Query from the Medusa container. It allows you to retrieve data across modules.

You retrieve the default invoice configuration and return it in the response.

Update Invoice Configurations API Route

Next, you'll add the API route to update the invoice configurations.

In the same file, add the following imports at the top of the file:

src/api/admin/invoice-config/route.ts
1import { z } from "zod"2import { 3  updateInvoiceConfigWorkflow,4} from "../../../workflows/update-invoice-config"

Then, add the following at the end of the file:

src/api/admin/invoice-config/route.ts
1export const PostInvoiceConfigSchema = z.object({2  company_name: z.string().optional(),3  company_address: z.string().optional(),4  company_phone: z.string().optional(),5  company_email: z.string().optional(),6  company_logo: z.string().optional(),7  notes: z.string().optional(),8})9
10type PostInvoiceConfig = z.infer<typeof PostInvoiceConfigSchema>11
12export async function POST(13  req: MedusaRequest<PostInvoiceConfig>,14  res: MedusaResponse15) {16  const { result: { invoice_config } } = await updateInvoiceConfigWorkflow(17    req.scope18  ).run({19    input: req.validatedBody,20  })21
22  res.json({23    invoice_config,24  })25}

You define a validation schema with Zod to validate incoming request bodies. The schema defines the fields that can be updated in the invoice configuration.

Next, since you export a POST route handler function, you expose a POST API route at /admin/invoice-config.

In the route handler, you execute the updateInvoiceConfigWorkflow by invoking it, passing it the Medusa container, then executing its run method.

You return the updated invoice configuration in the response.

Add Validation Middleware

To validate the body parameters of requests sent to the API route, you need to apply a middleware.

To apply a middleware to a route, create the file src/api/middlewares.ts with the following content:

src/api/middlewares.ts
1import { defineMiddlewares, validateAndTransformBody } from "@medusajs/framework/http"2import { PostInvoiceConfigSchema } from "./admin/invoice-config/route"3
4export default defineMiddlewares({5  routes: [6    {7      matcher: "/admin/invoice-config",8      methods: ["POST"],9      middlewares: [10        validateAndTransformBody(PostInvoiceConfigSchema),11      ],12    },13  ],14})

You apply Medusa's validateAndTransformBody middleware to POST requests sent to the /admin/invoice-config API route.

The middleware function accepts a Zod schema, which you created in the API route's file.

Tip: Refer to the Middlewares documentation to learn more.

c. Create Settings Page#

Next, you'll create a settings page in the Medusa Admin dashboard that allows admin users to manage the invoice configurations.

Initialize JS SDK

To send requests to the Medusa server, you'll use the JS SDK. It's already installed in your Medusa project, but you need to initialize it before using it in your customizations.

Create the file src/admin/lib/sdk.ts with the following content:

src/admin/lib/sdk.ts
1import Medusa from "@medusajs/js-sdk"2
3export const sdk = new Medusa({4  baseUrl: import.meta.env.VITE_BACKEND_URL || "/",5  debug: import.meta.env.DEV,6  auth: {7    type: "session",8  },9})

Learn more about the initialization options in the JS SDK reference.

Create Settings Page Component

Settings pages are UI routes created under the /src/admin/routes/settings directory. Medusa will then add the settings page under the Settings section of the Medusa Admin dashboard.

Note: Refer to the UI Routes documentation to learn more.

Create the file src/admin/routes/settings/invoice-config/page.tsx with the following content:

src/admin/routes/settings/invoice-config/page.tsx
1import { defineRouteConfig } from "@medusajs/admin-sdk"2import { Container, Heading, Button, Input, Label, Textarea, toast } from "@medusajs/ui"3import { useMutation, useQuery } from "@tanstack/react-query"4import { sdk } from "../../../lib/sdk"5import { useForm } from "react-hook-form"6import * as zod from "zod"7import { 8  FormProvider,9  Controller,10} from "react-hook-form"11import { useCallback, useEffect } from "react"12
13type InvoiceConfig = {14  id: string;15  company_name: string;16  company_address: string;17  company_phone: string;18  company_email: string;19  company_logo?: string;20  notes?: string;21}22
23const schema = zod.object({24  company_name: zod.string().optional(),25  company_address: zod.string().optional(),26  company_phone: zod.string().optional(),27  company_email: zod.string().email().optional(),28  company_logo: zod.string().url().optional(),29  notes: zod.string().optional(),30})31
32const InvoiceConfigPage = () => {33  // TODO add implementation34}35
36export const config = defineRouteConfig({37  label: "Default Invoice Config",38})39
40export default InvoiceConfigPage

A settings page file must export:

  1. A React component that renders the page. This is the file's default export.
  2. A configuration object created with the defineRouteConfig function. It accepts an object with properties that define the page's configuration, such as its sidebar label.

So far, the React component doesn't have any implementation. You also define a Zod schema that you'll use to validate the form data.

Change the implementation of the InvoiceConfigPage component to the following:

src/admin/routes/settings/invoice-config/page.tsx
1const InvoiceConfigPage = () => {2  const { data, isLoading, refetch } = useQuery<{3    invoice_config: InvoiceConfig4  }>({5    queryFn: () => sdk.client.fetch("/admin/invoice-config"),6    queryKey: ["invoice-config"],7  })8  const { mutateAsync, isPending } = useMutation({9    mutationFn: (payload: zod.infer<typeof schema>) => 10      sdk.client.fetch("/admin/invoice-config", {11        method: "POST",12        body: payload,13      }),14    onSuccess: () => {15      refetch()16      toast.success("Invoice config updated successfully")17    },18  })19
20  const getFormDefaultValues = useCallback(() => {21    return {22      company_name: data?.invoice_config.company_name || "",23      company_address: data?.invoice_config.company_address || "",24      company_phone: data?.invoice_config.company_phone || "",25      company_email: data?.invoice_config.company_email || "",26      company_logo: data?.invoice_config.company_logo || "",27      notes: data?.invoice_config.notes || "",28    }29  }, [data])30
31  const form = useForm<zod.infer<typeof schema>>({32    defaultValues: getFormDefaultValues(),33  })34
35  const handleSubmit = form.handleSubmit((formData) => mutateAsync(formData))36
37  const uploadLogo = async (event: React.ChangeEvent<HTMLInputElement>) => {38    const file = event.target.files?.[0]39    if (!file) {40      return41    }42
43    const { files } = await sdk.admin.upload.create({44      files: [file],45    })46
47    form.setValue("company_logo", files[0].url)48  }49
50  useEffect(() => {51    form.reset(getFormDefaultValues())52  }, [getFormDefaultValues])53
54
55  // TODO render form56}

In the component, you:

  1. Fetch the current invoice configuration using the useQuery hook from Tanstack Query and the JS SDK.
  2. Define a mutation using the useMutation hook to update the invoice configuration when the form is submitted.
  3. Define a function that returns the current invoice configuration data, which is useful to create the form.
  4. Define a form instance with useForm from react-hook-form. You pass it the Zod validation schema as a type argument, and the default values using the getFormDefaultValues function.
  5. Define a handleSubmit function that updates the invoice configuration using the mutation.
  6. Define an uploadLogo function that uploads a logo using Medusa's Upload API route.
  7. Reset the form's default values when the data changes, ensuring the form reflects the latest invoice configuration.

Finally, replace the TODO comment with the following return statement:

src/admin/routes/settings/invoice-config/page.tsx
1return (2  <Container className="divide-y p-0">3    <div className="flex items-center justify-between px-6 py-4">4      <Heading level="h1">Invoice Config</Heading>5    </div>6    <FormProvider {...form}>7      <form 8        onSubmit={handleSubmit}9        className="flex h-full flex-col overflow-hidden p-2 gap-2"10      >11        <Controller12          control={form.control}13          name="company_name"14          render={({ field }) => {15            return (16              <div className="flex flex-col space-y-2">17                <div className="flex items-center gap-x-1">18                  <Label size="small" weight="plus">19                    Company Name20                  </Label>21                </div>22                <Input {...field} onChange={field.onChange} value={field.value} />23              </div>24            )25          }}26        />27        <Controller28          control={form.control}29          name="company_address"30          render={({ field }) => {31            return (32              <div className="flex flex-col space-y-2">33                <div className="flex items-center gap-x-1">34                  <Label size="small" weight="plus">35                    Company Address36                  </Label>37                </div>38                <Textarea {...field} />39              </div>40            )41          }}42        />43        <Controller44          control={form.control}45          name="company_phone"46          render={({ field }) => {47            return (48              <div className="flex flex-col space-y-2">49                <div className="flex items-center gap-x-1">50                  <Label size="small" weight="plus">51                    Company Phone52                  </Label>53                </div>54                <Input {...field} />55              </div>56            )57          }}58        />59        <Controller60          control={form.control}61          name="company_email"62          render={({ field }) => {63            return (64              <div className="flex flex-col space-y-2">65                <div className="flex items-center gap-x-1">66                  <Label size="small" weight="plus">67                    Company Email68                  </Label>69                </div>70                <Input {...field} />71              </div>72            )73          }}74        />75        <Controller76          control={form.control}77          name="notes"78          render={({ field }) => {79            return (80              <div className="flex flex-col space-y-2">81                <div className="flex items-center gap-x-1">82                  <Label size="small" weight="plus">83                    Notes84                  </Label>85                </div>86                <Textarea {...field} />87              </div>88            )89          }}90        />91        <Controller92          control={form.control}93          name="company_logo"94          render={({ field }) => {95            return (96              <div className="flex flex-col space-y-2">97                <div className="flex items-center gap-x-1">98                  <Label size="small" weight="plus">99                    Company Logo100                  </Label>101                </div>102                <Input type="file" onChange={uploadLogo} className="py-1" />103                {field.value && (104                  <img105                    src={field.value}106                    alt="Company Logo"107                    className="mt-2 h-24 w-24"108                  />109                )}110              </div>111            )112          }}113        />114        <Button type="submit" disabled={isLoading || isPending}>115          Save116        </Button>117      </form>118    </FormProvider>119  </Container>120)

You render the form and its fields. When the form is submitted, you execute the handleSubmit function to update the invoice configuration.

Note: Refer to the Admin Components documentation to learn more about creating forms in the Medusa Admin dashboard.

Test Update Invoice Configurations#

You'll now test out the invoice configuration management feature.

First, start the Medusa application with the following command:

Then:

  1. Open the Medusa Admin dashboard in your browser at http://localhost:9000/admin and log in.
  2. Go to Settings -> Default Invoice Config.

You can edit any of the configurations and upload a company logo. When you click the Save button, it will update the configurations and display a success message.

Invoice Configurations Settings Page


Step 5: Implement Invoice Generation#

In this step, you'll implement the logic to generate an invoice PDF. Later, you'll execute this logic in a workflow, then expose the functionality in an API route.

You'll implement the invoice generation logic within the Invoice Generator Module's service.

First, install the dependencies needed for PDF generation:

Where:

  • pdfmake is a library for generating PDF documents in JavaScript.
  • @types/pdfmake provides TypeScript type definitions for pdfmake, allowing you to use it with TypeScript without type errors.

After that, add the following imports at the top of src/modules/invoice-generator/service.ts:

src/modules/invoice-generator/service.ts
1import { Invoice, InvoiceStatus } from "./models/invoice"2import PdfPrinter from "pdfmake"3import { 4  InferTypeOf, 5  OrderDTO, 6  OrderLineItemDTO,7} from "@medusajs/framework/types"8import axios from "axios"
Note: axios is available in your Medusa project by default, so you don't need to install it.

Next, add the following before the service declaration:

src/modules/invoice-generator/service.ts
1const fonts = {2  Helvetica: {3    normal: "Helvetica",4    bold: "Helvetica-Bold",5    italics: "Helvetica-Oblique",6    bolditalics: "Helvetica-BoldOblique",7  },8}9
10const printer = new PdfPrinter(fonts)11
12type GeneratePdfParams = {13  order: OrderDTO14  items: OrderLineItemDTO[]15}

You define the fonts to use in the PDF document. You can choose other fonts, as described in the pdfmake documentation.

Next, you initialize the PDF printer that will be used to generate the invoice PDF. You also define a type with the parameters necessary to generate an invoice.

In the service, you'll first add helper functions that are useful for generating the invoice.

Add the following methods to the InvoiceGeneratorService class:

src/modules/invoice-generator/service.ts
1class InvoiceGeneratorService extends MedusaService({2  InvoiceConfig,3  Invoice,4}) {5  private async formatAmount(amount: number, currency: string): Promise<string> {6    return new Intl.NumberFormat("en-US", {7      style: "currency",8      currency: currency,9    }).format(amount)10  }11
12  private async imageUrlToBase64(url: string): Promise<string> {13    const response = await axios.get(url, { responseType: "arraybuffer" })14    const base64 = Buffer.from(response.data).toString("base64")15    const mimeType = response.headers["content-type"] || "image/png"16    return `data:${mimeType};base64,${base64}`17  }18}

You add two methods:

  • formatAmount: Formats an amount into a string with its currency, which is useful for displaying amounts in the PDF.
  • imageUrlToBase64: Converts an image URL to a base64 string. This is necessary because pdfmake can't render images from URLs.

Next, you'll add the method that returns the PDF content as expected by pdfmake.

Add the following method to the InvoiceGeneratorService class:

src/modules/invoice-generator/service.ts
1class InvoiceGeneratorService extends MedusaService({2  InvoiceConfig,3  Invoice,4}) {5  // ...6  private async createInvoiceContent(7    params: GeneratePdfParams, 8    invoice: InferTypeOf<typeof Invoice>9  ): Promise<Record<string, any>> {10    // Get invoice configuration11    const invoiceConfigs = await this.listInvoiceConfigs()12    const config = invoiceConfigs[0] || {}13
14    // Create table for order items15    const itemsTable = [16      [17        { text: "Item", style: "tableHeader" },18        { text: "Quantity", style: "tableHeader" },19        { text: "Unit Price", style: "tableHeader" },20        { text: "Total", style: "tableHeader" },21      ],22      ...(await Promise.all(params.items.map(async (item) => [23        { text: item.title || "Unknown Item", style: "tableRow" },24        { text: item.quantity.toString(), style: "tableRow" },25        { text: await this.formatAmount(26          item.unit_price, 27          params.order.currency_code28        ), style: "tableRow" },29        { text: await this.formatAmount(30          Number(item.total), 31          params.order.currency_code32        ), style: "tableRow" },33      ]))),34    ]35
36    const invoiceId = `INV-${invoice.display_id.toString().padStart(6, "0")}`37    const invoiceDate = new Date(invoice.created_at).toLocaleDateString()38
39    // return the PDF content structure40    return {41      pageSize: "A4",42      pageMargins: [40, 60, 40, 60],43      header: {44        margin: [40, 20, 40, 0],45        columns: [46          /** Company Logo */47          {48            width: "*",49            stack: [50              ...(config.company_logo ? [51                {52                  image: await this.imageUrlToBase64(config.company_logo),53                  width: 80,54                  height: 40,55                  fit: [80, 40],56                  margin: [0, 0, 0, 10],57                },58              ] : []),59              {60                text: config.company_name || "Your Company Name",61                style: "companyName",62                margin: [0, 0, 0, 0],63              },64            ],65          },66          /** Invoice Title */67          {

This method returns a pdfmake document definition object that describes the structure and content of the invoice PDF.

You show in the PDF:

  • Company details, including the logo, name, address, phone, and email.
  • Invoice details, such as the invoice ID, date, order ID, and order date.
  • Billing and shipping addresses.
  • A table with the order items, including item name, quantity, unit price, and total.
  • A totals section that summarizes the subtotal, tax, shipping, discount, and total amounts.
  • An optional notes section for additional information.

You can customize the styles, layout, and content as needed to match your branding and requirements. Refer to the pdfmake documentation to learn about available content types.

Finally, you'll add the method that generates the PDF and returns it as a buffer.

Add the following method to the InvoiceGeneratorService class:

src/modules/invoice-generator/service.ts
1class InvoiceGeneratorService extends MedusaService({2  InvoiceConfig,3  Invoice,4}) {5  // ...6  async generatePdf(params: GeneratePdfParams & {7    invoice_id: string8  }): Promise<Buffer> {9    const invoice = await this.retrieveInvoice(params.invoice_id)10
11    // Generate new content12    const pdfContent = Object.keys(invoice.pdfContent).length ? 13      invoice.pdfContent : 14      await this.createInvoiceContent(params, invoice)15
16    await this.updateInvoices({17      id: invoice.id,18      pdfContent,19    })20
21    // get PDF as a Buffer22    return new Promise((resolve, reject) => {23      const chunks: Buffer[] = []24  25      const pdfDoc = printer.createPdfKitDocument(pdfContent as any)26      27      pdfDoc.on("data", (chunk) => chunks.push(chunk))28      pdfDoc.on("end", () => {29        const result = Buffer.concat(chunks)30        resolve(result)31      })32      pdfDoc.on("error", (err) => reject(err))33  34      pdfDoc.end() // Finalize PDF stream35    })36  }37}

In this method, you receive the ID of the invoice to generate its PDF, along with the order details.

You either retrieve its existing content, or generate new content. Finally, you return the PDF invoice as a Buffer.

You'll test out this functionality in the next step.


Step 6: Generate Invoice PDF Workflow and API Routes#

In this step, you'll create a workflow that generates an invoice PDF using the InvoiceGeneratorService you implemented in the previous step. Then, you'll create API routes for admin users and customers to download the generated invoice PDF.

a. Generate Invoice PDF Workflow#

The workflow that generates the invoice PDF will have the following steps:

You only need to implement the third and fourth steps, as the other two steps are helper steps that Medusa provides.

getOrderInvoiceStep

The getOrderInvoiceStep will either retrieve an existing invoice for an order, or create a new one.

Create the file src/workflows/steps/get-order-invoice.ts with the following content:

src/workflows/steps/get-order-invoice.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { INVOICE_MODULE } from "../../modules/invoice-generator"3import { InvoiceStatus } from "../../modules/invoice-generator/models/invoice"4
5type StepInput = {6  order_id: string7}8
9export const getOrderInvoiceStep = createStep(10  "get-order-invoice",11  async ({ order_id }: StepInput, { container }) => {12    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)13    let [invoice] = await invoiceGeneratorService.listInvoices({14      order_id,15      status: InvoiceStatus.LATEST,16    })17    let createdInvoice = false18
19    if (!invoice) {20      // Store new invoice in database21      invoice = await invoiceGeneratorService.createInvoices({22        order_id,23        status: InvoiceStatus.LATEST,24        pdfContent: {},25      })26      createdInvoice = true27    }28
29    return new StepResponse(invoice, {30      created_invoice: createdInvoice,31      invoice_id: invoice.id,32    })33  },34  async (data, { container }) => {35    const { created_invoice, invoice_id } = data || {}36    if (!created_invoice || !invoice_id) {37      return38    }39    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)40
41    invoiceGeneratorService.deleteInvoices(invoice_id)42  }43)

In the step, you try to retrieve an existing invoice with the latest status for the given order ID. If none exist, you create a new invoice.

The step returns the invoice. In the compensation function, you delete the invoice if an error occurs during the workflow execution.

generateInvoicePdfStep

The generateInvoicePdfStep will generate and return the invoice PDF as a buffer.

Create the file src/workflows/steps/generate-invoice-pdf.ts with the following content:

src/workflows/steps/generate-invoice-pdf.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { INVOICE_MODULE } from "../../modules/invoice-generator"3import { OrderDTO, OrderLineItemDTO } from "@medusajs/framework/types"4
5export type GenerateInvoicePdfStepInput = {6  order: OrderDTO7  items: OrderLineItemDTO[]8  invoice_id: string9}10
11export const generateInvoicePdfStep = createStep(12  "generate-invoice-pdf",13  async (input: GenerateInvoicePdfStepInput, { container }) => {14    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)15
16    const previousInv = await invoiceGeneratorService.retrieveInvoice(17      input.invoice_id18    )19
20    const pdfBuffer = await invoiceGeneratorService.generatePdf({21      order: input.order,22      items: input.items,23      invoice_id: input.invoice_id,24    })25
26    return new StepResponse({27      pdf_buffer: pdfBuffer,28    }, previousInv)29  },30  async (previousInv, { container }) => {31    if (!previousInv) {32      return33    }34
35    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)36
37    await invoiceGeneratorService.updateInvoices({38      id: previousInv.id,39      pdfContent: previousInv.pdfContent,40    })41  }42)

In the step, you first retrieve the invoice's data before generating the PDF. Then, you generate the PDF and return it as a buffer.

In the compensation function, you update the invoice with the previous PDF content if an error occurs during the workflow execution.

Generate Invoice Workflow

To create the workflow that generates invoices, create the file src/workflows/generate-invoice-pdf.ts with the following content:

src/workflows/generate-invoice-pdf.ts
1import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { generateInvoicePdfStep, GenerateInvoicePdfStepInput } from "./steps/generate-invoice-pdf"3import { useQueryGraphStep } from "@medusajs/medusa/core-flows"4import { getOrderInvoiceStep } from "./steps/get-order-invoice"5
6type WorkflowInput = {7  order_id: string8}9
10export const generateInvoicePdfWorkflow = createWorkflow(11  "generate-invoice-pdf",12  (input: WorkflowInput) => {13    const { data: orders } = useQueryGraphStep({14      entity: "order",15      fields: [16        "id",17        "display_id",18        "created_at",19        "currency_code",20        "total",21        "items.*",22        "items.variant.*",23        "items.variant.product.*",24        "shipping_address.*",25        "billing_address.*",26        "shipping_methods.*",27        "tax_total",28        "subtotal",29        "discount_total",30      ],31      filters: {32        id: input.order_id,33      },34      options: {35        throwIfKeyNotFound: true,36      },37    })38    const countryFilters = transform({39      orders,40    }, (data) => {41      const country_codes: string[] = []42      if (data.orders[0].billing_address?.country_code) {43        country_codes.push(data.orders[0].billing_address.country_code)44      }45      if (data.orders[0].shipping_address?.country_code) {46        country_codes.push(data.orders[0].shipping_address.country_code)47      }48      return country_codes49    })50    const { data: countries } = useQueryGraphStep({51      entity: "country",52      fields: ["display_name", "iso_2"],53      filters: {54        iso_2: countryFilters,55      },56    }).config({ name: "retrieve-countries" })57
58    const transformedOrder = transform({59      orders,60      countries,61    }, (data) => {62      const order = data.orders[0]63      64      if (order.billing_address?.country_code) {65        order.billing_address.country_code = data.countries.find(66          (country) => country.iso_2 === order.billing_address!.country_code67        )?.display_name || order.billing_address!.country_code68      }69      70      if (order.shipping_address?.country_code) {71        order.shipping_address.country_code = data.countries.find(72          (country) => country.iso_2 === order.shipping_address!.country_code73        )?.display_name || order.shipping_address!.country_code74      }75
76      return order77    })78
79    const invoice = getOrderInvoiceStep({80      order_id: transformedOrder.id,81    })82
83    const { pdf_buffer } = generateInvoicePdfStep({84      order: transformedOrder,85      items: transformedOrder.items,86      invoice_id: invoice.id,87    } as unknown as GenerateInvoicePdfStepInput)88
89    return new WorkflowResponse({90      pdf_buffer,91    })92  }93)

In the workflow, you:

  1. Retrieve the order details using useQueryGraphStep.
  2. Prepare the country codes filter from the billing and shipping addresses. You'll use this filter to retrieve the display names of the countries.
    • To manipulate data in a workflow, you need to use the transform function. Learn more in the Data Manipulation documentation.
  3. Retrieve the country details, including the display names, using useQueryGraphStep.
  4. Transform the order to replace country codes with display names.
  5. Retrieve or create the invoice using the getOrderInvoiceStep.
  6. Generate the invoice PDF using the generateInvoicePdfStep.
  7. Return the PDF buffer.

b. Generate Invoice Admin API Route#

Next, you'll create an API route that allows admin users to download an order's invoice.

Create the file src/api/admin/orders/[id]/invoices/route.ts with the following content:

src/api/admin/orders/[id]/invoices/route.ts
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework"2import { generateInvoicePdfWorkflow } from "../../../../../workflows/generate-invoice-pdf"3
4export async function GET(5  req: MedusaRequest,6  res: MedusaResponse7): Promise<void> {8  const { id } = req.params9
10  const { result: {11    pdf_buffer,12  } } = await generateInvoicePdfWorkflow(req.scope)13    .run({14      input: {15        order_id: id,16      },17    })18
19  const buffer = Buffer.from(pdf_buffer)20
21  res.set({22    "Content-Type": "application/pdf",23    "Content-Disposition": `attachment; filename="invoice-${id}.pdf"`,24    "Content-Length": buffer.length,25  })26  27  res.send(buffer)28}

You export a GET route handler function, exposing a GET API route at /admin/orders/:id/invoices.

In the route handler, you execute the generateInvoicePdfWorkflow which will return the PDF buffer.

Workflows and steps serialize their output. So, you must recreate the buffer from the serialized output using Buffer.from. Learn more in the Constructor Constraints documentation.

Finally, you set the response headers to indicate that the response is a PDF file and send the buffer as the response body.

c. Generate Invoice Store API Route#

You'll also create an identical API route for the storefront, allowing customers to download their order invoices.

Create the file src/api/store/orders/[id]/invoices/route.ts with the following content:

src/api/store/orders/[id]/invoices/route.ts
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework"2import { generateInvoicePdfWorkflow } from "../../../../../workflows/generate-invoice-pdf"3
4export async function GET(5  req: MedusaRequest,6  res: MedusaResponse7): Promise<void> {8  const { id } = req.params9
10  const { result: {11    pdf_buffer,12  } } = await generateInvoicePdfWorkflow(req.scope)13    .run({14      input: {15        order_id: id,16      },17    })18
19  const buffer = Buffer.from(pdf_buffer)20
21  res.set({22    "Content-Type": "application/pdf",23    "Content-Disposition": `attachment; filename="invoice-${id}.pdf"`,24    "Content-Length": buffer.length,25  })26  27  res.send(buffer)28}

You expose a GET API route at /store/orders/:id/invoices that allows customers to download their order invoices.

You'll test out both API routes in the next steps.


Step 7: Add Admin Widget to Download Invoices#

In this step, you'll create an admin widget that allows admin users to download invoices directly from order detail pages.

A widget is a React component that is injected into an existing admin page.

To create the widget, create the file src/admin/widgets/order-invoice.tsx with the following content:

src/admin/widgets/order-invoice.tsx
1import { defineWidgetConfig } from "@medusajs/admin-sdk"2import { Button, Container, Heading, Text, toast } from "@medusajs/ui"3import { AdminOrder, DetailWidgetProps } from "@medusajs/framework/types"4import { sdk } from "../lib/sdk"5import { useState } from "react"6
7const OrderInvoiceWidget = ({ data: order }: DetailWidgetProps<AdminOrder>) => {8  // TODO implement widget9}10
11export const config = defineWidgetConfig({12  zone: "order.details.side.before",13})14
15export default OrderInvoiceWidget

A widget file must export:

  1. A React component that contains the widget UI. It's the default export of the file.
  2. A widget configuration object that defines where the widget should be injected in the Medusa Admin.

Next, you'll add the implementation of the widget. Replace the implementation of the OrderInvoiceWidget component with the following code:

src/admin/widgets/order-invoice.tsx
1const OrderInvoiceWidget = ({ data: order }: DetailWidgetProps<AdminOrder>) => {2  const [isDownloading, setIsDownloading] = useState(false)3
4  const downloadInvoice = async () => {5    setIsDownloading(true)6    7    try {8      const response: Response = await sdk.client.fetch(9        `/admin/orders/${order.id}/invoices`, 10        {11          method: "GET",12          headers: {13            "accept": "application/pdf",14          },15        }16      )17  18      const blob = await response.blob()19      const url = window.URL.createObjectURL(blob)20      const a = document.createElement("a")21      a.href = url22      a.download = `invoice-${order.id}.pdf`23      document.body.appendChild(a)24      a.click()25      window.URL.revokeObjectURL(url)26      document.body.removeChild(a)27      setIsDownloading(false)28      toast.success("Invoice generated and downloaded successfully")29    } catch (error) {30      toast.error(`Failed to generate invoice: ${error}`)31      setIsDownloading(false)32    }33  }34
35  return (36    <Container className="divide-y p-0">37      <div className="flex items-center justify-between px-6 py-4">38        <div>39          <Heading level="h2">Invoice</Heading>40          <Text size="small" className="text-ui-fg-subtle">41            Generate and download invoice for this order42          </Text>43        </div>44      </div>45
46      <div className="flex items-center justify-end px-6 py-4">47        <Button48          variant="secondary"49          disabled={isDownloading}50          onClick={downloadInvoice}51          isLoading={isDownloading}52        >53          Download Invoice54        </Button>55      </div>56    </Container>57  )58}

The widget receives the order's data as a prop since it's injected into the order detail page.

In the widget, you define a downloadInvoice function that retrieves the PDF from the /admin/orders/:id/invoices API route and then triggers the download of the PDF.

You return a container with a button to download the invoice.

Test Admin Widget#

To test the admin widget, start the Medusa Admin application again.

Next, start the Next.js Starter Storefront to easily place an order:

Reminder: 

The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is {your-project}-storefront.

So, if your Medusa application's directory is medusa-invoice, you can find the storefront by going back to the parent directory and changing to the medusa-invoice-storefront directory:

Terminal
cd ../medusa-invoice-storefront # change based on your project name

Open the storefront at http://localhost:8000. Add a product to the cart, then place an order.

In the Medusa Admin, open the order's detail page. You'll see an "Invoice" section at the top right of the page. Click on the "Download Invoice" button to generate and download the invoice PDF.

Download Invoice Button in Admin Widget

You can view the downloaded invoice PDF to make sure all information is correct.

Tip: Making Changes to the Invoice Generator

After the first time the invoice is generated, the generatePdf method will use that same invoice content to avoid generating the content every time.

If you make changes to the createInvoiceContent method and you want to test it out, you'll have to place a new order for now. In a later step, you'll implement the functionality to mark an invoice as stale when an order is updated. This will allow you to easily test out changes to the invoice generation without placing a new order.


Step 8: Download Invoice in Storefront#

In this step, you'll customize the Next.js Starter Storefront to allow customers to download an order's invoice either from its confirmation or detail pages.

The OrderDetails component available in src/modules/order/components/order-details/index.tsx is used on both the order confirmation and detail pages. So, you only need to customize this component.

First, add the following at the top of the file:

Storefront
src/modules/order/components/order-details/index.tsx
1"use client"2
3// other imports...4import { Button, toast } from "@medusajs/ui"5import { useState } from "react"6import { sdk } from "../../../../lib/config"

You make the component a client component and you add the necessary imports.

Next, add the following variable and function in the OrderDetails component:

Storefront
src/modules/order/components/order-details/index.tsx
1const OrderDetails = ({ order, showStatus }: OrderDetailsProps) => {2  const [isDownloading, setIsDownloading] = useState(false)3
4  const downloadInvoice = async () => {5    setIsDownloading(true)6    7    try {8      const response: Response = await sdk.client.fetch(9        `/store/orders/${order.id}/invoices`, 10        {11          method: "GET",12          headers: {13            "accept": "application/pdf",14          },15        }16      )17  18      const blob = await response.blob()19      const url = window.URL.createObjectURL(blob)20      const a = document.createElement("a")21      a.href = url22      a.download = `invoice-${order.id}.pdf`23      document.body.appendChild(a)24      a.click()25      window.URL.revokeObjectURL(url)26      document.body.removeChild(a)27      setIsDownloading(false)28      toast.success("Invoice generated and downloaded successfully")29    } catch (error) {30      toast.error(`Failed to generate invoice: ${error}`)31      setIsDownloading(false)32    }33  }34
35  // ...36}

You define an isDownloading state variable to manage the download state and a downloadInvoice function that retrieves the PDF from the /store/orders/:id/invoices API route and triggers the download.

Finally, in the return statement, find the following lines:

Storefront
src/modules/order/components/order-details/index.tsx
1return (2  <div>3    {/* ... */}4    <Text className="mt-2 text-ui-fg-interactive">5      Order number: <span data-testid="order-id">{order.display_id}</span>6    </Text>7    {/* ... */}8  </div>9)

And replace them with the following:

Storefront
src/modules/order/components/order-details/index.tsx
1return (2  <div>3    {/* ... */}4    <div className="flex gap-2 items-center mt-2">5      <Text className="text-ui-fg-interactive">6        Order number: <span data-testid="order-id">{order.display_id}</span>7      </Text>8      <Button 9        variant="secondary" 10        onClick={downloadInvoice} 11        disabled={isDownloading} 12        isLoading={isDownloading}13      >14        Download Invoice15      </Button>16    </div>17    {/* ... */}18  </div>19)

You show the "Download Invoice" button next to the order number. When the customer clicks the button, it triggers the downloadInvoice function to download the invoice PDF.

Test Storefront Invoice Download#

To test the storefront changes, ensure both the Medusa application and the Next.js Starter Storefront are running.

Then, place an order in the storefront. You'll see the "Download Invoice" button on the order confirmation page. You can click the button to generate and download the invoice PDF.

Download Invoice Button in Storefront


Step 9: Send Invoice in Email Notifications#

In this step, you'll send an order confirmation email with the invoice PDF attached when an order is placed.

You can listen to events that occur in your Medusa application, such as when an order is placed, using subscribers. A subscriber is an asynchronous function that is executed whenever its associated event is emitted.

To create a subscriber, create the file src/subscribers/order-placed.ts with the following content:

src/subscribers/order-placed.ts
1import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"2import { generateInvoicePdfWorkflow } from "../workflows/generate-invoice-pdf"3
4export default async function orderPlacedHandler({5  event: { data },6  container,7}: SubscriberArgs<{8  id: string9}>) {10  const query = container.resolve("query")11  const notificationModuleService = container.resolve("notification")12
13  const { data: [order] } = await query.graph({14    entity: "order",15    fields: [16      "id",17      "display_id",18      "created_at",19      "currency_code",20      "total",21      "email",22      "items.*",23      "items.variant.*",24      "items.variant.product.*",25      "shipping_address.*",26      "billing_address.*",27      "shipping_methods.*",28      "tax_total",29      "subtotal",30      "discount_total",31      // add any other fields you need for the email template...32    ],33    filters: {34      id: data.id,35    },36  })37
38  const { result: {39    pdf_buffer,40  } } = await generateInvoicePdfWorkflow(container)41    .run({42      input: {43        order_id: data.id,44      },45    })46
47  const buffer = Buffer.from(pdf_buffer)48
49  // Convert to binary string to pass as attachment50  const binaryString = [...buffer]51    .map((byte) => byte.toString(2).padStart(8, "0"))52    .join("")53
54  await notificationModuleService.createNotifications({55    to: order.email || "",56    template: "order-placed",57    channel: "email",58    data: order,59    attachments: [60      {61        content: binaryString,62        filename: `invoice-${order.id}.pdf`,63        content_type: "application/pdf",64        disposition: "attachment",65      },66    ],67  })68}69
70export const config: SubscriberConfig = {71  event: "order.placed",72}

A subscriber file must export:

  • An asynchronous function, which is the subscriber function that is executed when the event is emitted.
  • A configuration object that defines the event the subscriber listens to.

In the subscriber, you:

  • Use Query to retrieve the order details. These details are useful to pass to the notification template.
  • Generate the invoice PDF using the generateInvoicePdfWorkflow.
  • Convert the PDF buffer to a binary string, which is required for the email attachment.
  • Send an email using the Notification Module with the order details and the invoice PDF attached.

Notification Module Provider to Use

Since the notification's channel is email, you need a Notification Module Provider that supports sending emails, such as SendGrid or Resend.

After setting up the Notification Module provider, make sure to replace the template of the notification with the template ID from the provider:

src/subscribers/order-placed.ts
1await notificationModuleService.createNotifications({2  template: "your-template-id", // Replace with your actual template ID3  // ...4})

Alternatively, for testing purposes, you can change the channel to feed. This will only log the notification to the console instead of sending an email:

src/subscribers/order-placed.ts
1await notificationModuleService.createNotifications({2  channel: "feed", // Change for testing3  // ...4})

Test Order Confirmation Email with Invoice#

To test the order confirmation email, make sure that the Medusa application and the Next.js Starter Storefront are running.

Then, place an order in the storefront. You'll see the following message in the Medusa application's console:

Terminal
info:    Processing order.placed which has 1 subscribers

The notification will either be sent to the customer's email address, or logged to the console if you set the channel to feed.


Step 10: Mark Invoices as Stale on Order Updates#

When an order is updated, you need to mark its existing invoice as stale so that the next time an invoice is generated, it includes the updated content. This ensures that admin users and customers always have access to an up-to-date invoice.

In this step, you'll create a workflow that marks an order's existing invoices as stale. Then, you'll execute the workflow in a subscriber that runs whenever an order is updated.

a. Mark Invoices as Stale Workflow#

The workflow that marks invoices as stale will have only a single step that updates the invoice status in the database.

Update Invoices Step

To create the step that updates the status of invoices, create the file src/workflows/steps/update-invoices.ts with the following content:

src/workflows/steps/update-invoices.ts
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { InvoiceStatus } from "../../modules/invoice-generator/models/invoice"3import { INVOICE_MODULE } from "../../modules/invoice-generator"4
5type StepInput = {6  selector: {7    order_id: string8  }9  data: {10    status: InvoiceStatus11  }12}13
14export const updateInvoicesStep = createStep(15  "update-invoices",16  async ({ selector, data }: StepInput, { container }) => {17    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)18
19    const prevData = await invoiceGeneratorService.listInvoices(20      selector21    )22
23    const updatedInvoices = await invoiceGeneratorService.updateInvoices({24      selector,25      data,26    })27
28    return new StepResponse(updatedInvoices, prevData)29  },30  async (prevData, { container }) => {31    if (!prevData) {32      return33    }34
35    const invoiceGeneratorService = container.resolve(INVOICE_MODULE)36
37    await invoiceGeneratorService.updateInvoices(38      prevData.map((i) => ({39        id: i.id,40        status: i.status,41      }))42    )43  }44)

In the step, you retrieve the invoices that belong to an order and update their status to stale. In the compensation function, you revert the status of the invoices back to their previous state in case the workflow execution fails.

The step returns the updated invoices.

Mark Invoices Stale Workflow

Next, to create the workflow that marks invoices as stale, create the file src/workflows/mark-invoices-stale.ts with the following content:

src/workflows/mark-invoices-stale.ts
1import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { updateInvoicesStep } from "./steps/update-invoices"4import { InvoiceStatus } from "../modules/invoice-generator/models/invoice"5
6type WorkflowInput = {7  order_id: string8}9
10export const markInvoicesStaleWorkflow = createWorkflow(11  "mark-invoices-stale",12  (input: WorkflowInput) => {13    const updatedInvoices = updateInvoicesStep({14      selector: {15        order_id: input.order_id,16      },17      data: {18        status: InvoiceStatus.STALE,19      },20    })21
22    return new WorkflowResponse({23      invoices: updatedInvoices,24    })25  }26)

The workflow accepts the ID of the order whose invoices should be marked as stale.

In the workflow, you update the invoices using the updateInvoicesStep, and return the updated invoices.

b. Create Order Updated Subscriber#

Next, you'll create a subscriber that listens to order updates and marks the order's invoices as stale.

Create the subscriber src/subscribers/order-updated.ts with the following content:

src/subscribers/order-updated.ts
1import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"2import { markInvoicesStaleWorkflow } from "../workflows/mark-invoices-stale"3
4type EventPayload = {5  id: string6} | {7  order_id: string8}9
10export default async function orderUpdatedHandler({11  event: { data },12  container,13}: SubscriberArgs<EventPayload>) {14  const orderId = "id" in data ? data.id : data.order_id15
16  await markInvoicesStaleWorkflow(container)17    .run({18      input: {19        order_id: orderId,20      },21    })22}23
24export const config: SubscriberConfig = {25  event: [26    "order.updated", 27    "order-edit.confirmed",28    "order.exchange_created",29    "order.claim_created",30    "order.return_received",31  ],32}

The subscriber will run whenever any of the following events are emitted:

  • order.updated: When an order's general details, such as billing or shipping address, are updated.
  • order-edit.confirmed: When an order edit, which may change the order's items, is confirmed.
  • order.exchange_created: When an exchange is confirmed for an order, which may change the order's items and totals.
  • order.claim_created: When a claim is confirmed for an order, which may change the order's items and totals.
  • order.return_received: When a return is confirmed and received for an order, which may change the order's items and totals.

In the subscriber, you execute the markInvoicesStaleWorkflow to mark the order's invoices as stale whenever an order is updated.

Note: Refer to the Events Reference for a complete list of events that Medusa emits.

Test Order Updates#

To test the order updates subscriber, start the Medusa application and go to an order's page.

On the order's page, try editing its billing or shipping address, or edit the order's items. You'll see the following message in the console:

Terminal
info:    Processing order.updated which has 1 subscribers

Afterward, try clicking the "Download Invoice" button. The new invoice will contain the order's updated details.


Next Steps#

You've successfully implemented the invoice generator feature in Medusa. You can expand on this feature to:

  1. Customize the PDF's content and layout to match your branding or include more details.
  2. Show all invoices for an order in the admin and storefront, allowing users to download previous invoices.
  3. Trigger sending invoices to customers from the Medusa Admin dashboard.

Learn More about Medusa#

If you're new to Medusa, check out the main documentation, where you'll get a more in-depth understanding of all the concepts you've used in this guide and more.

To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.

Troubleshooting#

If you encounter issues during your development, check out the troubleshooting guides.

Getting Help#

If you encounter issues not covered in the troubleshooting guides:

  1. Visit the Medusa GitHub repository to report issues or ask questions.
  2. Join the Medusa Discord community for real-time support from community members.
Was this page helpful?
Ask Anything
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break