Today, we shall be working on the orders module. This module ties in logic from all other modules, and is where the bulk of the order processing will be done. In case of any issues, you can refer to my Github repository here.

Orders Module

Here’s how the actual module file should look like. Nothing fancy here, mostly importing functionality from previous modules:

src/modules/orders/orders.module.ts view raw
import { Module } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from './entities/order.entity';
import { ProductsModule } from '../products/products.module';
import { StripeModule } from '../stripe/stripe.module';

@Module({
  imports: [TypeOrmModule.forFeature([Order]), ProductsModule, StripeModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

Orders Entity

There are only a few changes to the order entity. Don’t forget to add the paymentStatus attribute, with its corresponding properties:

src/modules/orders/entities/order.entity.ts view raw
import { Customer } from '../../customers/entities/customer.entity';
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { OrderItem } from './order-item.entity';
import { PaymentStatus } from '../../../common/enums/payment-status.enum';

@Entity('orders')
export class Order {
  constructor(intialData: Partial<Order> = null) {
    if (intialData !== null) {
      Object.assign(this, intialData);
    }
  }

  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'customer_id', type: 'uuid' })
  customerId: string;

  @Column({ name: 'total_amount', type: 'numeric' })
  totalAmount: number;

  @Column({
    name: 'payment_status',
    type: 'varchar',
    default: PaymentStatus.Created,
  })
  paymentStatus: PaymentStatus;

  @CreateDateColumn({
    name: 'created_at',
    type: 'timestamptz',
    default: 'now()',
    readonly: true,
  })
  createdAt: string;

  @UpdateDateColumn({
    name: 'updated_at',
    type: 'timestamptz',
    default: 'now()',
  })
  updatedAt: string;

  @ManyToOne(() => Customer, (customer) => customer.orders)
  @JoinColumn({ name: 'customer_id', referencedColumnName: 'id' })
  customer: Customer;

  @OneToMany(() => OrderItem, (orderItem) => orderItem.order, { cascade: true })
  orderItems: OrderItem[];
}

Order Items Entity

Our order item entity is similar to the one in previous tutorials:

src/modules/orders/entities/order-item.entity.ts view raw
import { Product } from '../../products/entities/product.entity';
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { Order } from './order.entity';

@Entity('order_items')
export class OrderItem {
  constructor(intialData: Partial<OrderItem> = null) {
    if (intialData !== null) {
      Object.assign(this, intialData);
    }
  }

  @PrimaryColumn({ name: 'order_id', type: 'uuid' })
  orderId: string;

  @PrimaryColumn({ name: 'product_id', type: 'uuid' })
  productId: string;

  @Column({ name: 'unit_price', type: 'numeric' })
  unitPrice: number;

  @Column({ type: 'numeric' })
  quantity: number;

  @ManyToOne(() => Product, (product) => product.orderItems)
  @JoinColumn({ name: 'product_id', referencedColumnName: 'id' })
  product: Product;

  @ManyToOne(() => Order, (order) => order.orderItems)
  @JoinColumn({ name: 'order_id', referencedColumnName: 'id' })
  order: Order;
}

Create Order DTO

Onto our DTOs. We’ll first start with the create order DTO:

src/modules/orders/dto/create-order.dto.ts view raw
import { IsNotEmpty, IsNumber } from 'class-validator';
import { OrderItem } from '../entities/order-item.entity';

export class CreateOrderDto {
  @IsNumber()
  @IsNotEmpty()
  totalAmount: number;

  @IsNotEmpty()
  orderItems: OrderItem[];
}

We’re basically making sure that in the request body for creating a new order, we are receiving the totalAmount as a string, and orderItems as an array.

Update Order DTO

Here’s the update order DTO:

src/modules/orders/dto/update-order.dto.ts view raw
import { PartialType } from '@nestjs/mapped-types';
import { CreateOrderDto } from './create-order.dto';

export class UpdateOrderDto extends PartialType(CreateOrderDto) {}

It inherits properties from the create order DTO.

Order Enums

We’ll create a couple of enumerable classes to contain our payment statuses and payment intent event statuses:

src/common/enums/payment-status.enum.ts view raw
export enum PaymentStatus {
  Created = 'Created',
  Processing = 'Processing',
  Succeeded = 'Succeeded',
  Failed = 'Failed',
}
src/common/enums/payment-intent-event.enum.ts view raw
export enum PaymentIntentEvent {
  Succeeded = 'payment_intent.succeeded',
  Processing = 'payment_intent.processing',
  Failed = 'payment_intent.payment_failed',
}

Orders Service

Here’s our orders service, with some comments added to aid in understanding the new functionality:

src/modules/orders/orders.service.ts view raw
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { CreateOrderDto } from './dto/create-order.dto';
import { Customer } from '../customers/entities/customer.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Order } from './entities/order.entity';
import { Repository, UpdateResult } from 'typeorm';
import { ProductsService } from '../products/products.service';
import { StripeService } from '../stripe/stripe.service';
import { Stripe } from 'stripe';
import { PaymentStatus } from '../../common/enums/payment-status.enum';
import { PaymentIntentEvent } from '../../common/enums/payment-intent-event.enum';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order)
    private readonly ordersRepository: Repository<Order>,
    private readonly productsService: ProductsService,
    private readonly stripeService: StripeService,
  ) {}

  async create(
    createOrderDto: CreateOrderDto,
    customer: Customer,
  ): Promise<Order> {
    // Check if the products in the order exist
    const productIds = createOrderDto.orderItems.map((item) => item.productId);
    const products = await this.productsService.checkIfProductsExist(
      productIds,
    );

    // If none of the products in the order exist, or only some exist, while others
    // do not, throw an exception
    if (!products || products.length != productIds.length) {
      throw new UnprocessableEntityException(
        'The order could not be processed',
      );
    }

    const order = new Order({
      customerId: customer.id,
      totalAmount: createOrderDto.totalAmount,
    });

    order.orderItems = createOrderDto.orderItems;

    // Save the order including its order items as a transaction
    const savedOrder = await this.ordersRepository.save(order);

    // Create a payment intent on Stripe
    const paymentIntent = await this.stripeService.createPaymentIntent(
      savedOrder.id,
      savedOrder.totalAmount,
    );
    const clientSecret = paymentIntent.client_secret;

    // Return the client secret to the client as well as the saved order info
    const updatedOrder = { ...savedOrder, clientSecret: clientSecret };
    return updatedOrder;
  }

  async findOrder(id: string): Promise<Order> {
    return await this.ordersRepository.findOneOrFail(id);
  }

  async updateOrder(id: string, order: Order): Promise<UpdateResult> {
    await this.findOrder(id);
    return await this.ordersRepository.update(id, order);
  }

  async updatePaymentStatus(event: Stripe.Event): Promise<string> {
    // Fetch the orderId from the webhook metadata
    const orderId = event.data.object['metadata'].orderId;

    // Lookup the order
    const order = await this.findOrder(orderId);

    // Check the event type
    switch (event.type) {
      // If the event type is a succeeded, update the payment status to succeeded
      case PaymentIntentEvent.Succeeded:
        order.paymentStatus = PaymentStatus.Succeeded;
        break;

      case PaymentIntentEvent.Processing:
        // If the event type is processing, update the payment status to processing
        order.paymentStatus = PaymentStatus.Processing;
        break;

      case PaymentIntentEvent.Failed:
        // If the event type is payment_failed, update the payment status to payment_failed
        order.paymentStatus = PaymentStatus.Failed;
        break;

      default:
        // else, by default the payment status should remain as created
        order.paymentStatus = PaymentStatus.Created;
        break;
    }

    const updateResult = await this.updateOrder(orderId, order);

    if (updateResult.affected === 1) {
      return `Record successfully updated with Payment Status ${order.paymentStatus}`;
    } else {
      throw new UnprocessableEntityException(
        'The payment was not successfully updated',
      );
    }
  }
}

The create method assists in creating a new order. Before creating a new order we first start by validating the information contained in the order payload that we receive. We first check if indeed the products in the order exist, and if either none of them exist or just some of them do, we reject the order and throw an exception. Once we have successfully saved the order, we create a stripe payment intent using the stripe service (created in the previous tutorial). Once the payment intent is successfully created on the Stripe servers, a client secret will be returned that is used to complete the payment on stripe. We add this client secret to the response body of the order and return it back to the client.

The updatePaymentStatus method is used by the webhook that we created in the previous tutorial, and is used to set the final payment status of our orders depending on whether the customer’s selected payment status was successful. We by default set all orders that we receive to have a payment status of Created. We then update this status depending on what we receive from the stripe webhook.

Orders Controller

The orders controller has two methods: create and webhook that call the respective service methods in the orders service. We protect the create method using an auth guard since we want only authenticated customers to create orders:

src/modules/orders/orders.controller.ts view raw
import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Request } from 'express';
import { Customer } from '../customers/entities/customer.entity';
import { Stripe } from 'stripe';
import { Order } from './entities/order.entity';

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @UseGuards(JwtAuthGuard)
  @Post()
  create(
    @Body() createOrderDto: CreateOrderDto,
    @Req() req: Request,
  ): Promise<Order> {
    const customer = req.user as Customer;
    return this.ordersService.create(createOrderDto, customer);
  }

  @Post('stripe_webhook')
  async webhook(@Body() event: Stripe.Event): Promise<object> {
    await this.ordersService.updatePaymentStatus(event);
    return { message: 'success' };
  }
}

The webhook method is for updating the order payment status.

Unit Tests

As is our practice, we shall be including a unit test for both our controller and service, and they’ll look like so:

Controller Spec:

src/modules/orders/spec/orders.controller.spec.ts view raw
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersController } from '../orders.controller';
import { OrdersService } from '../orders.service';

describe('OrdersController', () => {
  let controller: OrdersController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [OrdersController],
      providers: [
        {
          provide: OrdersService,
          useValue: {},
        },
      ],
    }).compile();

    controller = module.get<OrdersController>(OrdersController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

This only includes a basic test for definition, as most of the functionality will be tested in an e2e spec.

Service Spec:

src/modules/orders/spec/orders.service.spec.ts view raw
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import Stripe from 'stripe';
import { UpdateResult } from 'typeorm';
import { Customer } from '../../customers/entities/customer.entity';
import { ProductsService } from '../../products/products.service';
import { StripeService } from '../../stripe/stripe.service';
import { CreateOrderDto } from '../dto/create-order.dto';
import { Order } from '../entities/order.entity';
import { OrdersService } from '../orders.service';

describe('OrdersService', () => {
  let service: OrdersService;

  beforeEach(async () => {
    const createMock = jest.fn((dto: any) => {
      return dto;
    });

    const saveMock = jest.fn((dto: any) => {
      return dto;
    });

    const findOneMock = jest.fn((dto: any) => {
      return dto;
    });

    const MockRepository = jest.fn().mockImplementation(() => {
      return {
        create: createMock,
        save: saveMock,
        findOne: findOneMock,
      };
    });

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OrdersService,
        {
          provide: getRepositoryToken(Order),
          useClass: MockRepository,
        },
        {
          provide: ProductsService,
          useValue: {},
        },
        {
          provide: StripeService,
          useValue: {},
        },
      ],
    }).compile();

    service = module.get<OrdersService>(OrdersService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('creating an order', () => {
    it('should return a new order', async () => {
      let order: Promise<Order>;
      const testOrderDto = new CreateOrderDto();
      const testCustomer = new Customer();

      jest.spyOn(service, 'create').mockImplementation(() => order);
      expect(await service.create(testOrderDto, testCustomer)).toBe(order);
    });
  });

  describe('finding an order', () => {
    it('should return one order', async () => {
      let order: Promise<Order>;
      const testOrderId = '2173dd22-42ed-4091-bb46-aaca401efa46';

      jest.spyOn(service, 'findOrder').mockImplementation(() => order);
      expect(await service.findOrder(testOrderId)).toBe(order);
    });
  });

  describe('updating an order', () => {
    it('should return an update result', async () => {
      let updateResult: Promise<UpdateResult>;
      const testOrderId = '2173dd22-42ed-4091-bb46-aaca401efa46';
      const testOrder = new Order();

      jest.spyOn(service, 'updateOrder').mockImplementation(() => updateResult);
      expect(await service.updateOrder(testOrderId, testOrder)).toBe(
        updateResult,
      );
    });
  });

  describe('updating an order payment status', () => {
    it('should return a string', async () => {
      let updateResult: Promise<string>;
      let event: Stripe.Event;

      jest
        .spyOn(service, 'updatePaymentStatus')
        .mockImplementation(() => updateResult);
      expect(await service.updatePaymentStatus(event)).toBe(updateResult);
    });
  });
});

As before, we heavily use mocks to test the methods in our orders service.

Our tests should turn green when run:

Orders Controller Spec

Orders Service Spec

Postman Tests

We’ll use postman as usual for our manual tests. Here’s our order creation endpoint:

Create Order

We’re creating an order with a total value of $55.25.

Don’t forget to run the stripe CLI in a separate terminal tab.

stripe listen --forward-to localhost:3000/orders/stripe_webhook

Once an order is created, you should see some output printed by the stripe CLI:

Stripe CLI output

The payment intent has been created on stripe’s servers, and in turn we are informed of the same via a payment_intent.created event.

We can confirm the same when we login to our stripe dashboard:

Stripe Dashboard Payment Created

Our payment has a status of Incomplete on the stripe dashboard, since according to our chosen payment flow, we’ll require frontend UI to prompt the user to select a valid payment method (credit/debit card, klarna or ali pay), then submit all the relevant details to complete the payment. We’ll cover this in a future tutorial.