update payment

This commit is contained in:
2024-11-03 20:28:14 +07:00
parent e8c0e0d312
commit bc2eda7490
10 changed files with 208 additions and 14 deletions

68
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@graphql-codegen/typescript-resolvers": "^4.2.1", "@graphql-codegen/typescript-resolvers": "^4.2.1",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/apollo": "^12.2.0", "@nestjs/apollo": "^12.2.0",
"@nestjs/axios": "^3.1.1",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.3", "@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
@@ -24,6 +25,7 @@
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.4.2", "@nestjs/swagger": "^7.4.2",
"@payos/node": "^1.0.10",
"@pothos/core": "^4.3.0", "@pothos/core": "^4.3.0",
"@pothos/plugin-add-graphql": "^4.1.0", "@pothos/plugin-add-graphql": "^4.1.0",
"@pothos/plugin-authz": "^3.5.10", "@pothos/plugin-authz": "^3.5.10",
@@ -38,6 +40,7 @@
"@smatch-corp/nestjs-pothos": "^0.3.0", "@smatch-corp/nestjs-pothos": "^0.3.0",
"@smatch-corp/nestjs-pothos-apollo-driver": "^0.1.0", "@smatch-corp/nestjs-pothos-apollo-driver": "^0.1.0",
"apollo-server-express": "^3.13.0", "apollo-server-express": "^3.13.0",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
@@ -4284,6 +4287,17 @@
"integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@nestjs/axios": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.1.tgz",
"integrity": "sha512-ySoxrzqX80P1q6LKLKGcgyBd2utg4gbC+4FsJNpXYvILorMlxss/ECNogD9EXLCE4JS5exVFD5ez0nK5hXcNTQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
"axios": "^1.3.1",
"rxjs": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@nestjs/cli": { "node_modules/@nestjs/cli": {
"version": "10.4.5", "version": "10.4.5",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz",
@@ -4928,6 +4942,16 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@payos/node": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@payos/node/-/node-1.0.10.tgz",
"integrity": "sha512-dY+WHd6pLa558a1G8yv6oKfVe5QLTNyYnQBaSQtwvMAm/p0faKAnfXt04LNIwO9/4buas4ES+sDxc1bfX/mVbQ==",
"license": "ISC",
"dependencies": {
"axios": "^1.5.0",
"crypto": "^1.0.1"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -7163,6 +7187,17 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -8388,6 +8423,13 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.",
"license": "ISC"
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
@@ -9816,6 +9858,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -14947,6 +15009,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pug": { "node_modules/pug": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz",

View File

@@ -39,6 +39,7 @@
"@graphql-codegen/typescript-resolvers": "^4.2.1", "@graphql-codegen/typescript-resolvers": "^4.2.1",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/apollo": "^12.2.0", "@nestjs/apollo": "^12.2.0",
"@nestjs/axios": "^3.1.1",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.3", "@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
@@ -46,6 +47,7 @@
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.4.2", "@nestjs/swagger": "^7.4.2",
"@payos/node": "^1.0.10",
"@pothos/core": "^4.3.0", "@pothos/core": "^4.3.0",
"@pothos/plugin-add-graphql": "^4.1.0", "@pothos/plugin-add-graphql": "^4.1.0",
"@pothos/plugin-authz": "^3.5.10", "@pothos/plugin-authz": "^3.5.10",
@@ -60,6 +62,7 @@
"@smatch-corp/nestjs-pothos": "^0.3.0", "@smatch-corp/nestjs-pothos": "^0.3.0",
"@smatch-corp/nestjs-pothos-apollo-driver": "^0.1.0", "@smatch-corp/nestjs-pothos-apollo-driver": "^0.1.0",
"apollo-server-express": "^3.13.0", "apollo-server-express": "^3.13.0",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",

View File

@@ -152,7 +152,6 @@ export class Builder extends SchemaBuilder<SchemaBuilderOption> {
this.queryType({}) this.queryType({})
this.mutationType({}) this.mutationType({})
this.subscriptionType({}) this.subscriptionType({})
this.globalConnectionField('totalCount', (t) => this.globalConnectionField('totalCount', (t) =>
t.int({ t.int({
nullable: true, nullable: true,

View File

@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { OrderSchema } from './order.schema' import { OrderSchema } from './order.schema'
import { PayosModule } from 'src/Payos/payos.module'
@Module({ @Module({
imports: [PayosModule],
providers: [OrderSchema], providers: [OrderSchema],
exports: [OrderSchema], exports: [OrderSchema],
}) })

View File

@@ -8,11 +8,14 @@ import {
import { Builder } from '../Graphql/graphql.builder' import { Builder } from '../Graphql/graphql.builder'
import { PrismaService } from '../Prisma/prisma.service' import { PrismaService } from '../Prisma/prisma.service'
import { OrderStatus } from '@prisma/client' import { OrderStatus } from '@prisma/client'
import { DateTimeUtils } from 'src/common/utils/datetime.utils'
import { PayosService } from 'src/Payos/payos.service'
@Injectable() @Injectable()
export class OrderSchema extends PothosSchema { export class OrderSchema extends PothosSchema {
constructor( constructor(
@Inject(SchemaBuilderToken) private readonly builder: Builder, @Inject(SchemaBuilderToken) private readonly builder: Builder,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly payosService: PayosService,
) { ) {
super() super()
} }
@@ -121,11 +124,24 @@ export class OrderSchema extends PothosSchema {
required: true, required: true,
}), }),
}, },
resolve: async (query, _root, args, _ctx, _info) => { resolve: async (query, _root, args, ctx, _info) => {
return this.prisma.$transaction(async (prisma) => { return this.prisma.$transaction(async (prisma) => {
if (ctx.isSubscription) {
throw new Error('Subscription is not allowed')
}
if (!args.data.service.connect?.id) {
throw new Error('Service not found')
}
const order = await prisma.order.create({ const order = await prisma.order.create({
...query, ...query,
data: args.data, data: {
status: OrderStatus.PENDING,
total:
(args.data.service.connect?.price as number | undefined) ?? 0,
userId: ctx.http.me.id,
serviceId: args.data.service.connect.id,
scheduleId: args.data.scheduleId,
},
}) })
// check if service is valid // check if service is valid
if (!args.data.service.connect) { if (!args.data.service.connect) {
@@ -135,15 +151,32 @@ export class OrderSchema extends PothosSchema {
if (args.data.service.connect.price === 0) { if (args.data.service.connect.price === 0) {
return order return order
} }
// generate payment code by prefix 'EPESS' + 6 hex digits // random integer
const paymentCode = 'EPESS' + Math.random().toString(16).slice(2, 8) const paymentCode = Math.floor(Math.random() * 1000000)
// create payment // create payment
await prisma.payment.create({ const payment = await prisma.payment.create({
data: { data: {
orderId: order.id, orderId: order.id,
amount: args.data.service.connect.price as number, amount: args.data.service.connect.price as number,
paymentCode: paymentCode, paymentCode: paymentCode.toString(),
expiredAt: new Date(Date.now() + 1000 * 60 * 60 * 24), expiredAt: DateTimeUtils.now().plus({ minutes: 15 }).toJSDate(),
},
})
// generate payment url
const paymentData = await this.payosService.createPayment({
orderCode: paymentCode,
amount: args.data.service.connect.price as number,
description: args.data.service.connect.name as string,
buyerName: ctx.http.me.name as string,
buyerEmail: ctx.http.me.email as string,
returnUrl: `${process.env.PAYOS_WEBHOOK_URL}/return`,
cancelUrl: `${process.env.PAYOS_WEBHOOK_URL}/cancel`,
})
// update payment url
await prisma.payment.update({
where: { id: payment.id },
data: {
paymentCode: paymentData.paymentLinkId,
}, },
}) })
return order return order

View File

@@ -32,4 +32,18 @@ export class PayosController {
async ping() { async ping() {
return this.payosService.ping() return this.payosService.ping()
} }
// test create payment url
@Post('create-payment-url')
@ApiOperation({ summary: 'Test create payment url' })
async createPaymentURL(@Body() body: any) {
return this.payosService.createPaymentURL(body)
}
// get payment status
@Get('get-payment-status/:orderId')
@ApiOperation({ summary: 'Get payment status' })
async getPaymentStatus(@Param('orderId') orderId: string | number) {
return this.payosService.getPaymentStatus(orderId)
}
} }

View File

@@ -1,9 +1,24 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { PayosController } from './payos.controller' import { PayosController } from './payos.controller'
import { PayosService } from './payos.service' import { PayosService } from './payos.service'
import { HttpModule } from '@nestjs/axios'
import PayOS from '@payos/node'
@Module({ @Module({
providers: [PayosService], imports: [HttpModule],
providers: [
PayosService,
{
provide: 'PayOS',
useFactory: () => {
return new PayOS(
process.env.PAYOS_CLIENT_ID ?? '',
process.env.PAYOS_API_KEY ?? '',
process.env.PAYOS_CHECKSUM_KEY ?? '',
)
},
},
],
controllers: [PayosController], controllers: [PayosController],
exports: [PayosService], exports: [PayosService],
}) })

View File

@@ -1,10 +1,19 @@
import { Injectable, Logger } from '@nestjs/common' import { Inject, Injectable, Logger } from '@nestjs/common'
import { PrismaService } from '../Prisma/prisma.service' import { PrismaService } from '../Prisma/prisma.service'
import PayOS from '@payos/node'
import type {
CheckoutRequestType,
CheckoutResponseDataType,
} from '@payos/node/lib/type'
export type CreatePaymentBody = CheckoutRequestType
export type CreatePaymentResponse = CheckoutResponseDataType
@Injectable() @Injectable()
export class PayosService { export class PayosService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
@Inject('PayOS') private readonly payos: PayOS,
) {}
async ping() { async ping() {
return 'pong' return 'pong'
@@ -16,7 +25,15 @@ export class PayosService {
} }
async createPaymentURL(body: any) { async createPaymentURL(body: any) {
return body return await this.payos.createPaymentLink(body)
}
async createPayment(body: CreatePaymentBody): Promise<CreatePaymentResponse> {
return await this.payos.createPaymentLink(body)
}
async getPaymentStatus(orderId: string | number) {
return await this.payos.getPaymentLinkInformation(orderId)
} }
async cancelPaymentURL(body: any) { async cancelPaymentURL(body: any) {

View File

@@ -72,6 +72,20 @@ export class ScheduleSchema extends PothosSchema {
}) })
} }
@PothosRef()
scheduleConnection() {
return this.builder.simpleObject('ScheduleConnection', {
fields: (t) => ({
totalCount: t.int({
nullable: true,
}),
schedules: t.field({
type: [this.schedule()],
}),
}),
})
}
@PothosRef() @PothosRef()
scheduleSlot() { scheduleSlot() {
return this.builder.simpleObject('ScheduleSlot', { return this.builder.simpleObject('ScheduleSlot', {
@@ -326,6 +340,28 @@ d72a864e-2f41-45ab-9c9b-bf0512a31883,e9be51fd-2382-4e43-9988-74e76fde4b56,2024-1
}) })
}, },
}), }),
updateScheduleStatus: t.prismaField({
type: this.schedule(),
description: 'Update a schedule status.',
args: {
scheduleId: t.arg({
type: 'String',
required: true,
}),
status: t.arg({
type: ScheduleStatus,
required: true,
}),
},
resolve: async (query, _root, args, _ctx, _info) => {
return await this.prisma.schedule.update({
...query,
where: { id: args.scheduleId },
data: { status: args.status },
})
},
}),
})) }))
} }
} }

View File

@@ -26,6 +26,14 @@ export type TimeType = {
@Injectable() @Injectable()
export class DateTimeUtils { export class DateTimeUtils {
static nowAsJSDate(): Date {
return DateTime.now().toJSDate()
}
static now(): DateTime {
return DateTime.now()
}
static getOverlapRange( static getOverlapRange(
startA: DateTime, startA: DateTime,
endA: DateTime, endA: DateTime,