Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions prisma/migrations/20260203073133_add_method_column/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
Warnings:

- The values [kakaopay,tosspay] on the enum `Payment_provider` will be removed. If these variants are still used in the database, this will fail.

*/
-- AlterTable
ALTER TABLE `Payment` ADD COLUMN `cash_receipt_type` VARCHAR(191) NULL,
ADD COLUMN `cash_receipt_url` VARCHAR(191) NULL,
ADD COLUMN `method` ENUM('CARD', 'VIRTUAL_ACCOUNT', 'TRANSFER', 'MOBILE', 'EASY_PAY') NOT NULL DEFAULT 'CARD',
MODIFY `provider` ENUM('TOSSPAYMENTS', 'KAKAOPAY', 'NAVERPAY', 'TOSSPAY', 'SAMSUNGPAY', 'APPLEPAY', 'LPAY', 'PAYCO', 'SSG', 'PINPAY') NOT NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Payment` ALTER COLUMN `method` DROP DEFAULT;
34 changes: 28 additions & 6 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -392,17 +392,44 @@ model Purchase {
@@index([user_id], map: "Purchase_user_id_fkey")
}

// ๊ฒฐ์ œ ์ˆ˜๋‹จ
enum PaymentMethod {
CARD // ์นด๋“œ
VIRTUAL_ACCOUNT // ๊ฐ€์ƒ๊ณ„์ขŒ
TRANSFER // ๊ณ„์ขŒ์ด์ฒด
MOBILE // ํœด๋Œ€ํฐ
EASY_PAY // ๊ฐ„ํŽธ๊ฒฐ์ œ
}

// ๊ตฌ์ฒด์ ์ธ PG์‚ฌ ๋˜๋Š” ๊ฐ„ํŽธ๊ฒฐ์ œ์‚ฌ
enum PaymentProvider {
TOSSPAYMENTS // ์ผ๋ฐ˜ ํ† ์Šค PG (์นด๋“œ, ๊ฐ€์ƒ๊ณ„์ขŒ ๋“ฑ)
KAKAOPAY // ์นด์นด์˜คํŽ˜์ด
NAVERPAY // ๋„ค์ด๋ฒ„ํŽ˜์ด
TOSSPAY // ํ† ์ŠคํŽ˜์ด
SAMSUNGPAY // ์‚ผ์„ฑํŽ˜์ด
APPLEPAY // ์• ํ”ŒํŽ˜์ด
LPAY // ์—˜ํŽ˜์ด
PAYCO // ํŽ˜์ด์ฝ”
SSG // SSGํŽ˜์ด
PINPAY // ํ•€ํŽ˜์ด
}

model Payment {
payment_id Int @id @default(autoincrement())
purchase_id Int @unique
status Status
provider Payment_provider
method PaymentMethod
provider PaymentProvider
merchant_uid String @unique
created_at DateTime @default(now())
updated_at DateTime @updatedAt
imp_uid String @unique
purchase Purchase @relation(fields: [purchase_id], references: [purchase_id], onDelete: Cascade)
settlement Settlement?
// ํ˜„๊ธˆ์˜์ˆ˜์ฆ ์ •๋ณด (๊ฐ€์ƒ๊ณ„์ขŒ/๊ณ„์ขŒ์ด์ฒด ์‹œ)
cash_receipt_url String? // ์˜์ˆ˜์ฆ ์กฐํšŒ URL
cash_receipt_type String? // ์†Œ๋“๊ณต์ œ(DEDUCTION), ์ง€์ถœ์ฆ๋น™(PROOF) ๋“ฑ
}

model Settlement {
Expand Down Expand Up @@ -588,11 +615,6 @@ enum NotificationType {
ADMIN_MESSAGE
}

enum Payment_provider {
kakaopay
tosspay
}

enum userStatus {
active
banned
Expand Down
4 changes: 3 additions & 1 deletion src/purchases/dtos/purchase.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { PaymentProvider } from "@prisma/client";

export interface PurchaseHistoryItemDTO {
prompt_id: number;
title: string;
price: number;
seller_nickname: string;
pg: 'kakaopay' | 'tosspay';
pg: PaymentProvider | null;
}

export interface PurchaseHistoryResponseDTO {
Expand Down
9 changes: 0 additions & 9 deletions src/purchases/dtos/purchase.request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
export interface PromptPurchaseRequestDTO {
prompt_id: number;
pg: 'kakaopay' | 'tosspay'; // ๊ฒฐ์ œ ์ˆ˜๋‹จ
merchant_uid: string; // ๊ณ ์œ  ์ฃผ๋ฌธ๋ฒˆํ˜ธ
amount: number;
buyer_name: string;
redirect_url: string;
custom_data: {
prompt_id: number;
user_id: number;
};
}
20 changes: 13 additions & 7 deletions src/purchases/repositories/purchase.complete.repository.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Prisma } from '@prisma/client';
import { Prisma, PaymentMethod, PaymentProvider, Status } from '@prisma/client';

type Tx = Prisma.TransactionClient;

export const PurchaseCompleteRepository = {
createPurchaseTx(tx: Tx, data: {
createPurchaseTx(tx: Tx, data: {
user_id: number;
prompt_id: number;
seller_id?: number;
Expand All @@ -16,17 +16,23 @@ export const PurchaseCompleteRepository = {
createPaymentTx(tx: Tx, data: {
purchase_id: number;
merchant_uid: string;
pg: 'kakaopay' | 'tosspay';
status: 'Succeed' | 'Failed' | 'Pending';
method: PaymentMethod;
provider: PaymentProvider;
status: Status;
paymentId: string;
cash_receipt_url?: string | null;
cash_receipt_type?: string | null;
}) {
return tx.payment.create({
data: {
purchase: { connect: { purchase_id: data.purchase_id } },
merchant_uid: data.merchant_uid,
provider: data.pg,
status: data.status,
imp_uid: data.paymentId,
method: data.method,
provider: data.provider,
status: data.status,
cash_receipt_url: data.cash_receipt_url,
cash_receipt_type: data.cash_receipt_type,
},
});
},
Expand All @@ -36,7 +42,7 @@ export const PurchaseCompleteRepository = {
paymentId: number;
amount: number;
fee: number;
status: 'Succeed' | 'Failed' | 'Pending';
status: Status;
}) {
return tx.settlement.upsert({
where: { payment_id: input.paymentId },
Expand Down
91 changes: 27 additions & 64 deletions src/purchases/routes/purchase.request.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const router = Router();
* @swagger
* /api/prompts/purchases/requests:
* post:
* summary: ๊ฒฐ์ œ ์š”์ฒญ ์ƒ์„ฑ
* description: ๊ฒฐ์ œ ์‹œ์ž‘์„ ์œ„ํ•œ ์š”์ฒญ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
* summary: ๊ฒฐ์ œ ์š”์ฒญ ์ƒ์„ฑ (์‚ฌ์ „ ๊ฒ€์ฆ)
* description: ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๊ฒฐ์ œ์ฐฝ์„ ๋„์šฐ๊ธฐ ์ „, ์ฃผ๋ฌธ ๋ฒˆํ˜ธ ์ƒ์„ฑ ๋ฐ ์‚ฌ์ „ ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
* tags: [Purchase]
* security:
* - jwt: []
Expand All @@ -28,55 +28,48 @@ const router = Router();
* application/json:
* schema:
* type: object
* required:
* - prompt_id
* - merchant_uid
* - amount
* - buyer_name
* - redirect_url
* properties:
* prompt_id:
* type: integer
* pg:
* type: string
* enum: [kakaopay, tosspayments]
* merchant_uid:
* type: string
* description: ๊ฐ€๋งน์  ์ฃผ๋ฌธ ๋ฒˆํ˜ธ
* amount:
* type: integer
* description: ๊ฒฐ์ œ ์˜ˆ์ • ๊ธˆ์•ก
* buyer_name:
* type: string
* redirect_url:
* type: string
* responses:
* 200:
* description: ๊ฒฐ์ œ ์š”์ฒญ ์ƒ์„ฑ ์„ฑ๊ณต
* description: ์š”์ฒญ ์„ฑ๊ณต
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* payment_gateway:
* type: string
* merchant_uid:
* type: string
* redirect_url:
* type: string
* statusCode:
* type: integer
* 400:
* description: ์ž˜๋ชป๋œ ์š”์ฒญ
* 401:
* description: ์ธ์ฆ ์‹คํŒจ
* 404:
* description: ๋ฆฌ์†Œ์Šค ์—†์Œ
* 409:
* description: ์ค‘๋ณต/์ƒํƒœ ์ถฉ๋Œ
*/
router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurchase);

/**
* @swagger
* /api/prompts/purchases/complete:
* post:
* summary: ๊ฒฐ์ œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ(Webhook/๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ํ›„ ์„œ๋ฒ„ ๊ฒ€์ฆ)
* description: ํฌํŠธ์› imp_uid ๊ธฐ๋ฐ˜์œผ๋กœ ์„œ๋ฒ„์—์„œ ๊ฒฐ์ œ ๊ฒ€์ฆ ํ›„ ๊ตฌ๋งค/๊ฒฐ์ œ/์ •์‚ฐ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค.
* summary: ๊ฒฐ์ œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ (๊ฒ€์ฆ ๋ฐ ์ €์žฅ)
* description: ํฌํŠธ์› ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„, paymentId๋ฅผ ์„œ๋ฒ„๋กœ ๋ณด๋‚ด ๊ฒ€์ฆํ•˜๊ณ  ๊ตฌ๋งค๋ฅผ ํ™•์ •ํ•ฉ๋‹ˆ๋‹ค.
* tags: [Purchase]
* security:
* - jwt: []
Expand All @@ -86,14 +79,18 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch
* application/json:
* schema:
* type: object
* required:
* - paymentId
* properties:
* imp_uid:
* paymentId:
* type: string
* description: ํฌํŠธ์› V2 ๊ฒฐ์ œ ID
* merchant_uid:
* type: string
* description: ๊ฐ€๋งน์  ์ฃผ๋ฌธ ๋ฒˆํ˜ธ
* responses:
* 200:
* description: ๊ฒฐ์ œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ ์„ฑ๊ณต
* description: ๊ฒฐ์ œ ์„ฑ๊ณต ๋ฐ ์ €์žฅ ์™„๋ฃŒ
* content:
* application/json:
* schema:
Expand All @@ -106,54 +103,23 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch
* enum: [Succeed, Failed, Pending]
* purchase_id:
* type: integer
* nullable: true
* statusCode:
* type: integer
* 400:
* description: ๊ฒ€์ฆ ์‹คํŒจ/์œ ํšจํ•˜์ง€ ์•Š์€ ์š”์ฒญ
* 401:
* description: ์ธ์ฆ ์‹คํŒจ
* 404:
* description: ๋ฆฌ์†Œ์Šค ์—†์Œ
* 409:
* description: ์ถฉ๋Œ
* 500:
* description: ์„œ๋ฒ„ ์˜ค๋ฅ˜
*/
router.post('/complete', authenticateJwt, PurchaseCompleteController.completePurchase);

/**
* @swagger
* /api/prompts/purchases:
* get:
* summary: ๋‚ด ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ
* description: ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ๊ฒฐ์ œ(๊ตฌ๋งค) ๋‚ด์—ญ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
* summary: ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ
* description: ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ๊ฒฐ์ œ ๋‚ด์—ญ์„ ์ตœ์‹ ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
* tags: [Purchase]
* security:
* - jwt: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* required: false
* description: ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (์˜ต์…˜)
* - in: query
* name: pageSize
* schema:
* type: integer
* required: false
* description: ํŽ˜์ด์ง€ ํฌ๊ธฐ (์˜ต์…˜)
* - in: query
* name: status
* schema:
* type: string
* enum: [Succeed, Failed, Pending]
* required: false
* description: ๊ฒฐ์ œ ์ƒํƒœ ํ•„ํ„ฐ (์˜ต์…˜)
* responses:
* 200:
* description: ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ ์„ฑ๊ณต
* description: ์กฐํšŒ ์„ฑ๊ณต
* content:
* application/json:
* schema:
Expand All @@ -172,21 +138,18 @@ router.post('/complete', authenticateJwt, PurchaseCompleteController.completePur
* type: string
* price:
* type: integer
* seller_nickname:
* type: string
* purchased_at:
* type: string
* format: date-time
* seller_nickname:
* type: string
* nullable: true
* pg:
* type: string
* enum: [kakaopay, tosspay, null]
* description: ๊ฒฐ์ œ ์ œ๊ณต์ž (DB Enum)
* enum: [TOSSPAYMENTS, KAKAOPAY, TOSSPAY, NAVERPAY, SAMSUNGPAY, APPLEPAY, LPAY, PAYCO, SSG, PINPAY]
* nullable: true
* statusCode:
* type: integer
* 401:
* description: ์ธ์ฆ ์‹คํŒจ
* 500:
* description: ์„œ๋ฒ„ ์˜ค๋ฅ˜
*/
router.get('/', authenticateJwt, PurchaseHistoryController.list);

Expand Down
11 changes: 5 additions & 6 deletions src/purchases/services/purchase.complete.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { PurchaseCompleteRepository } from '../repositories/purchase.complete.re
import { AppError } from '../../errors/AppError';
import prisma from '../../config/prisma';
import { fetchAndVerifyPortonePayment } from '../utils/portone';
import { mapPgProvider } from '../utils/payment.util';

export const PurchaseCompleteService = {
async completePurchase(userId: number, dto: PromptPurchaseCompleteRequestDTO): Promise<PromptPurchaseCompleteResponseDTO> {
Expand Down Expand Up @@ -32,9 +31,6 @@ export const PurchaseCompleteService = {
throw new AppError('์ด๋ฏธ ๊ตฌ๋งคํ•œ ํ”„๋กฌํ”„ํŠธ์ž…๋‹ˆ๋‹ค.', 409, 'AlreadyPurchased');
}

// 5. ํŠธ๋žœ์žญ์…˜ ์ฒ˜๋ฆฌ
const pgProvider = mapPgProvider(verifiedPayment.method_provider);

const { purchase_id } = await prisma.$transaction(async (tx) => {
// ๊ตฌ๋งค ๊ธฐ๋ก ์ƒ์„ฑ
const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, {
Expand All @@ -49,9 +45,12 @@ export const PurchaseCompleteService = {
const payment = await PurchaseCompleteRepository.createPaymentTx(tx, {
purchase_id: purchase.purchase_id,
merchant_uid: paymentId,
pg: pgProvider,
paymentId: paymentId,
status: 'Succeed',
paymentId: paymentId
method: verifiedPayment.method,
provider: verifiedPayment.provider,
cash_receipt_url: verifiedPayment.cashReceipt?.url,
cash_receipt_type: verifiedPayment.cashReceipt?.type,
});

// ์ •์‚ฐ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ
Expand Down
15 changes: 9 additions & 6 deletions src/purchases/services/purchase.request.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { v4 as uuidv4 } from 'uuid';
import { PromptPurchaseRequestDTO } from '../dtos/purchase.request.dto';
import { PurchaseRequestRepository } from '../repositories/purchase.request.repository';
import { AppError } from '../../errors/AppError';
Expand All @@ -12,16 +13,18 @@ export const PurchaseRequestService = {
const existing = await PurchaseRequestRepository.findExistingPurchase(userId, dto.prompt_id);
if (existing) throw new AppError('์ด๋ฏธ ๊ตฌ๋งคํ•œ ํ”„๋กฌํ”„ํŠธ์ž…๋‹ˆ๋‹ค.', 409, 'AlreadyPurchased');

const paymentId = `payment-${uuidv4()}`;

return {
message: '๊ฒฐ์ œ ์š”์ฒญ์ด ์ •์ƒ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
payment_gateway: dto.pg,
merchant_uid: dto.merchant_uid,
redirect_url: dto.redirect_url, // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋„˜๊ธด ๊ฐ’ ์‚ฌ์šฉ
custom_data: {
message: '์ฃผ๋ฌธ์„œ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
statusCode: 200,
merchant_uid: paymentId,
amount: prompt.price,
prompt_title: prompt.title,
custom_data: {
prompt_id: dto.prompt_id,
user_id: userId,
},
statusCode: 200,
};
},
};
Loading