Skip to content

Commit

Permalink
Merge pull request #40 from zulfikarrosadi/feat/payment
Browse files Browse the repository at this point in the history
Feat/payment
  • Loading branch information
zulfikarrosadi authored Nov 20, 2024
2 parents f6d14ce + d39b839 commit 2db640e
Show file tree
Hide file tree
Showing 8 changed files with 627 additions and 10 deletions.
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ SERVER_PORT=
DB_DATABASE=
TOKEN_SECRET=
EMAIL=
EMAIL_PASSWORD=
EMAIL_PASSWORD=
MIDTRANS_SERVER_KEY=
MIDTRANS_BASE_URL=
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
66 changes: 57 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-3.0.x", "debian-openssl-1.1.x"]
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-3.0.x", "debian-openssl-1.1.x"]
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}

Expand All @@ -16,6 +16,7 @@ model products {
price Float
images Json?
ratings ratings[]
orders orders[]
}

model ratings {
Expand All @@ -30,15 +31,49 @@ model ratings {
}

model users {
id BigInt @id @default(autoincrement())
id BigInt @id @default(autoincrement())
fullname String
email String @unique
email String @unique
password String
refresh_token String?
email_verified Boolean? @default(false)
verification_token String? @db.Char(6)
role Role @default(USER)
email_verified Boolean? @default(false)
verification_token String? @db.Char(6)
role Role @default(USER)
ratings ratings[]
orders orders[]
adresses adresses[]
}

model adresses {
id Int @id @default(autoincrement())
province String
city String
subdistrict String
village String
postcal_code String @db.Char(6)
full_address String
user_id BigInt
user users @relation(fields: [user_id], references: [id])
orders orders[]
}

model orders {
id String @id
transaction_id String?
user_id BigInt
product_id BigInt
amount Int // how many product user bought
total_price String // amount x product price
transaction_status Transaction_Status?
created_at BigInt
completed_at BigInt?
payment_type String?
address_id Int
user users @relation(fields: [user_id], references: [id])
product products @relation(fields: [product_id], references: [id])
address adresses @relation(fields: [address_id], references: [id])
}

enum crdb_internal_region {
Expand All @@ -49,3 +84,16 @@ enum Role {
ADMIN
USER
}

enum Transaction_Status {
capture
settlement
pending
deny
cancel
expire
failure
refund
partial_refund
authorize
}
67 changes: 67 additions & 0 deletions src/payment/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Request, Response } from 'express'
import type ApiResponse from '../schema'
import type { OrderWebhookPayload, PaymentRequest } from './schema'

type OrderTokenResponse = {
token: string
redirect_url: string
}

type OrderStatus = {
order_id: string
transaction_id: string
transaction_status: string
product_id: bigint
product_name: string
total_price: string
completed_at: bigint | null
created_at: bigint
}

interface PaymentService {
requestOrderToken(
data: PaymentRequest,
): Promise<ApiResponse<OrderTokenResponse>>
orderWebhookReceiver(data: OrderWebhookPayload): Promise<ApiResponse<any>>
checkOrderStatus(orderId: string): Promise<ApiResponse<OrderStatus>>
}

class PaymentHandler {
constructor(private service: PaymentService) {}

requestOrderToken = async (
req: Request<Record<string, any>, Record<string, any>, PaymentRequest>,
res: Response<ApiResponse<OrderTokenResponse>>,
) => {
const result = await this.service.requestOrderToken(req.body)
if (result.status === 'fail') {
return res.status(result.errors.code).json(result)
}
return res.status(201).json(result)
}

orderWebhook = async (
req: Request<Record<string, any>, Record<string, any>, OrderWebhookPayload>,
res: Response,
) => {
const result = await this.service.orderWebhookReceiver(req.body)
if (result.status === 'fail') {
return res.status(result.errors.code).json(result)
}
return res.sendStatus(200)
}

checkOrderStatus = async (
req: Request<{ orderId: string }>,
res: Response,
) => {
const { orderId: id } = req.params
const result = await this.service.checkOrderStatus(id)
if (result.status === 'fail') {
return res.status(result.errors.code).json(result)
}
return res.status(200).json(result)
}
}

export default PaymentHandler
184 changes: 184 additions & 0 deletions src/payment/repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { PrismaClient } from '@prisma/client'
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
import { BadRequestError, NotFoundError } from '../lib/Error'
import { type Logger, getContext } from '../lib/logger'
import type { SaveOrder } from './schema'
import type { PaymentRequest } from './schema'

class PaymentRepository {
constructor(
private prisma: PrismaClient,
private logger: Logger,
) {}
private RELATED_RECORD_NOT_EXIST = 'P2025'
/**
* this could happen because race condition in upsert operation
*/
private CONSTRAINT_ERROR = 'P2002'

async createOrder(data: PaymentRequest) {
try {
const result = await this.prisma.orders.create({
data: {
id: data.order_id,
product: {
connect: {
id: data.transaction_details.product_id,
},
},
user: {
connect: {
email: data.customer_details.email,
},
},
address: {
connect: {
id: data.customer_details.address_id,
},
},
total_price: `${data.transaction_details.gross_amount}`,
created_at: new Date().getTime(),
amount: data.transaction_details.product_count_total,
},
select: {
id: true,
product: { select: { id: true, name: true } },
user: { select: { id: true, email: true, fullname: true } },
address: { select: { full_address: true } },
created_at: true,
amount: true,
total_price: true,
},
})

return result
} catch (error: any) {
const context = getContext()
this.logger(
'error',
`fail to create order: ${error.message || error}`,
'repository',
'createOrder',
context,
)
throw new BadRequestError('fail to create order, please try again later')
}
}

async updateOrderStatus(data: SaveOrder) {
try {
if (
data.transaction_status === 'settlement' ||
data.transaction_status === 'capture'
) {
const result = await this.prisma.orders.update({
where: {
id: data.order_id,
},
data: {
transaction_id: data.transaction_id,
completed_at: new Date().getTime(),
transaction_status: data.transaction_status,
payment_type: data.payment_type,
},
})
return result
}

const result = await this.prisma.orders.update({
where: {
id: data.order_id,
},
data: {
transaction_id: data.transaction_id,
transaction_status: data.transaction_status,
payment_type: data.payment_type,
},
})
return result
} catch (error: any) {
const context = getContext()
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === this.RELATED_RECORD_NOT_EXIST) {
this.logger(
'error',
`foreign key fail: ${error.message}`,
'repository',
'createOrUpdateOrder',
context,
)
}
if (error.code === this.CONSTRAINT_ERROR) {
this.logger(
'error',
`race condition happen: ${error.message}`,
'repository',
'createOrUpdateOrder',
context,
)
}
}
this.logger(
'error',
error.message || error,
'repository',
'createOrUpdateOrder',
context,
)
throw new BadRequestError(
'fail to place an order, please try again later',
)
}
}

async getOrderStatus(orderId: string) {
try {
const result = await this.prisma.orders.findUniqueOrThrow({
where: {
id: orderId,
},
select: {
id: true,
transaction_id: true,
transaction_status: true,
product: {
select: {
id: true,
name: true,
},
},
total_price: true,
completed_at: true,
created_at: true,
},
})
return result
} catch (error: any) {
const context = getContext()
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === this.RELATED_RECORD_NOT_EXIST) {
this.logger(
'error',
`get order status failed, order id not found ${error.message}`,
'repository',
'getOrderStatus',
context,
)
throw new NotFoundError('get order status failed, order is not found')
}
this.logger(
'error',
`get order status failed, bad request: ${error.message || error}`,
'repository',
'getOrderStatus',
context,
)
throw new BadRequestError(
'get order status failed, please try again later and make sure to provide valid information',
)
}
}
}
}

export default PaymentRepository
Loading

0 comments on commit 2db640e

Please # to comment.