This article is Part 9 in a 10 Part Series.
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:
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:
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:
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:
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:
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:
export enum PaymentStatus {
Created = ' Created ' ,
Processing = ' Processing ' ,
Succeeded = ' Succeeded ' ,
Failed = ' Failed ' ,
}
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:
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:
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:
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:
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:
Postman Tests
We’ll use postman as usual for our manual tests. Here’s our order creation endpoint:
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:
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:
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.