diff --git a/package.json b/package.json index c42c20f..e72da9c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.848.0", "@aws-sdk/s3-request-presigner": "^3.848.0", + "@portone/server-sdk": "^0.19.0", "@prisma/client": "^6.14.0", "@types/express-session": "^1.18.2", "@types/passport-google-oauth20": "^2.0.16", @@ -27,6 +28,7 @@ "axios": "^1.11.0", "bcrypt": "^6.0.0", "bcryptjs": "^3.0.2", + "body-parser": "^2.2.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cors": "^2.8.5", @@ -58,6 +60,7 @@ "@types/axios": "^0.14.4", "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^3.0.0", + "@types/body-parser": "^1.19.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb7858..6864609 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.848.0 version: 3.928.0 + '@portone/server-sdk': + specifier: ^0.19.0 + version: 0.19.0 '@prisma/client': specifier: ^6.14.0 version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3) @@ -35,6 +38,9 @@ importers: bcryptjs: specifier: ^3.0.2 version: 3.0.3 + body-parser: + specifier: ^2.2.2 + version: 2.2.2 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -123,6 +129,9 @@ importers: '@types/bcryptjs': specifier: ^3.0.0 version: 3.0.0 + '@types/body-parser': + specifier: ^1.19.6 + version: 1.19.6 '@types/cors': specifier: ^2.8.19 version: 2.8.19 @@ -647,6 +656,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@portone/server-sdk@0.19.0': + resolution: {integrity: sha512-JIbD9HEEqaYuuW0zKU/sapqH1HQBf/Len+8ZPnMRb7E7tHnqRGrKPLS+fom34xUTiF3ZVKTrDjHeQHQ2YZc2og==} + '@prisma/client@6.19.0': resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==} engines: {node: '>=18.18'} @@ -1255,8 +1267,8 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.2.0: - resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} bowser@2.12.1: @@ -2111,10 +2123,6 @@ packages: resolution: {integrity: sha512-D90rbOiZuEJGtmIBK9wcRpW//ZKLD8bTPOAx5oEsu+O+HhSOstX/HCZFBvNkuyDuiNHunb81cfsqaYzZxcUMYA==} engines: {node: '>=0.8.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} @@ -3061,6 +3069,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + random-bytes@1.0.0: resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} engines: {node: '>= 0.8'} @@ -4714,6 +4726,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@portone/server-sdk@0.19.0': {} + '@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.0(typescript@5.9.3) @@ -5510,15 +5524,15 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.2.0: + body-parser@2.2.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.0 - iconv-lite: 0.6.3 + iconv-lite: 0.7.0 on-finished: 2.4.1 - qs: 6.14.0 + qs: 6.14.1 raw-body: 3.0.1 type-is: 2.0.1 transitivePeerDependencies: @@ -6243,7 +6257,7 @@ snapshots: express@5.1.0: dependencies: accepts: 2.0.0 - body-parser: 2.2.0 + body-parser: 2.2.2 content-disposition: 1.0.0 content-type: 1.0.5 cookie: 0.7.2 @@ -6634,10 +6648,6 @@ snapshots: iconv-lite@0.4.8: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 @@ -7617,6 +7627,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + random-bytes@1.0.0: {} range-parser@1.2.1: {} diff --git a/src/purchases/controller/purchase.complete.controller.ts b/src/purchases/controller/purchase.complete.controller.ts index 1fbfa6f..24e86fd 100644 --- a/src/purchases/controller/purchase.complete.controller.ts +++ b/src/purchases/controller/purchase.complete.controller.ts @@ -5,16 +5,13 @@ import { PurchaseCompleteService } from '../services/purchase.complete.service'; export const PurchaseCompleteController = { async completePurchase(req: Request, res: Response, next: NextFunction) { try { - console.log('๐Ÿ”ฅ ์š”์ฒญ ๋ฐ”๋”” ํ™•์ธ:', req.body); // โ† ์—ฌ๊ธฐ์— ๋กœ๊ทธ ์ฐ๊ธฐ - console.log('๐Ÿ”ฅ Content-Type:', req.headers['content-type']); // โ† ํ—ค๋”๋„ ํ™•์ธ - const userId = (req.user as any).user_id; const dto = req.body as Partial; - if (!dto || typeof dto.imp_uid !== 'string' || typeof dto.merchant_uid !== 'string') { + if (!dto || typeof dto.paymentId !== 'string') { return res.status(400).json({ error: 'BadRequest', - message: 'imp_uid์™€ merchant_uid๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.', + message: 'paymentId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.', statusCode: 400, }); } diff --git a/src/purchases/controller/purchase.webhook.controller.ts b/src/purchases/controller/purchase.webhook.controller.ts new file mode 100644 index 0000000..0dbddf8 --- /dev/null +++ b/src/purchases/controller/purchase.webhook.controller.ts @@ -0,0 +1,38 @@ +import { Request, Response, NextFunction } from 'express'; +import * as PortOne from '@portone/server-sdk'; +import { WebhookService } from '../services/purchase.webhook.service'; + +export const WebhookController = { + async handleWebhook(req: Request, res: Response, next: NextFunction) { + try { + const webhookSecret = process.env.PORTONE_WEBHOOK_SECRET; + if (!webhookSecret) { + console.error('PORTONE_WEBHOOK_SECRET is not set'); + return res.status(500).send('Server Config Error'); + } + + // 1. ์›นํ›… ์„œ๋ช… ๊ฒ€์ฆ + const webhook = await PortOne.Webhook.verify( + webhookSecret, + req.body, + req.headers as Record + ); + + // 2. ์ด๋ฒคํŠธ ํƒ€์ž…๋ณ„ ์ฒ˜๋ฆฌ -> ํ˜„์žฌ๋Š” ๊ฒฐ์ œ ์™„๋ฃŒ(Paid)๋งŒ ์ฒ˜๋ฆฌ + if (webhook.type === 'Transaction.Paid') { + const { paymentId, storeId } = webhook.data; + await WebhookService.handleTransactionPaid(paymentId, storeId); + } else if (webhook.type === 'Transaction.Cancelled') { + console.log('[Webhook] Transaction Cancelled:', webhook.data.paymentId); + } + res.status(200).send('OK'); + } catch (err) { + if (err instanceof PortOne.Webhook.WebhookVerificationError) { + console.error('[Webhook] Signature Verification Failed'); + return res.status(400).send('Verification Failed'); + } + console.error('[Webhook] Error:', err); + res.status(500).send('Internal Server Error'); + } + } +}; \ No newline at end of file diff --git a/src/purchases/dtos/purchase.complete.dto.ts b/src/purchases/dtos/purchase.complete.dto.ts index ccc5249..64851e1 100644 --- a/src/purchases/dtos/purchase.complete.dto.ts +++ b/src/purchases/dtos/purchase.complete.dto.ts @@ -1,6 +1,5 @@ export interface PromptPurchaseCompleteRequestDTO { - imp_uid: string; // ํฌํŠธ์› ๊ฒฐ์ œ UID - merchant_uid: string; // ์ฃผ๋ฌธ๋ฒˆํ˜ธ + paymentId: string; } export interface PromptPurchaseCompleteResponseDTO { diff --git a/src/purchases/dtos/purchase.request.dto.ts b/src/purchases/dtos/purchase.request.dto.ts index 2692104..3fadb02 100644 --- a/src/purchases/dtos/purchase.request.dto.ts +++ b/src/purchases/dtos/purchase.request.dto.ts @@ -7,5 +7,6 @@ export interface PromptPurchaseRequestDTO { redirect_url: string; custom_data: { prompt_id: number; + user_id: number; }; } \ No newline at end of file diff --git a/src/purchases/repositories/purchase.complete.repository.ts b/src/purchases/repositories/purchase.complete.repository.ts new file mode 100644 index 0000000..c1d129d --- /dev/null +++ b/src/purchases/repositories/purchase.complete.repository.ts @@ -0,0 +1,57 @@ +import { Prisma } from '@prisma/client'; + +type Tx = Prisma.TransactionClient; + +export const PurchaseCompleteRepository = { + createPurchaseTx(tx: Tx, data: { + user_id: number; + prompt_id: number; + seller_id?: number; + amount: number; + is_free: false; + }) { + return tx.purchase.create({ data }); + }, + + createPaymentTx(tx: Tx, data: { + purchase_id: number; + merchant_uid: string; + pg: 'kakaopay' | 'tosspay'; + status: 'Succeed' | 'Failed' | 'Pending'; + paymentId: string; + }) { + 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, + }, + }); + }, + + upsertSettlementForPaymentTx(tx: Tx, input: { + sellerId: number; + paymentId: number; + amount: number; + fee: number; + status: 'Succeed' | 'Failed' | 'Pending'; + }) { + return tx.settlement.upsert({ + where: { payment_id: input.paymentId }, + create: { + user_id: input.sellerId, + payment_id: input.paymentId, + amount: input.amount, + fee: input.fee, + status: input.status, + }, + update: { + amount: input.amount, + fee: input.fee, + status: input.status, + }, + }); + } +}; \ No newline at end of file diff --git a/src/purchases/repositories/purchase.repository.ts b/src/purchases/repositories/purchase.repository.ts new file mode 100644 index 0000000..f6c3d67 --- /dev/null +++ b/src/purchases/repositories/purchase.repository.ts @@ -0,0 +1,27 @@ +import prisma from '../../config/prisma'; + +export const PurchaseRepository = { + findSucceededByUser(userId: number) { + return prisma.purchase.findMany({ + where: { + user_id: userId, + payment: { is: { status: 'Succeed'}}, + }, + include: { + prompt: { + select: { + prompt_id: true, + title: true, + user: { select: { nickname: true}}, + }, + }, + payment: { + select: { + provider: true, + }, + }, + }, + orderBy: { created_at: 'desc'}, + }) + } +}; \ No newline at end of file diff --git a/src/purchases/repositories/purchase.request.repository.ts b/src/purchases/repositories/purchase.request.repository.ts index 6c00bb1..0645947 100644 --- a/src/purchases/repositories/purchase.request.repository.ts +++ b/src/purchases/repositories/purchase.request.repository.ts @@ -1,13 +1,10 @@ import prisma from '../../config/prisma'; -import { Prisma } from '@prisma/client'; -type Tx = Prisma.TransactionClient; - -export const PurchaseRepository = { +export const PurchaseRequestRepository = { findPromptWithSeller(prompt_id: number) { return prisma.prompt.findUnique({ where: { prompt_id }, - include: { user: true }, // ํŒ๋งค์ž user + include: { user: true }, }); }, @@ -19,81 +16,5 @@ export const PurchaseRepository = { payment: { is: { status: 'Succeed' } }, }, }); - }, - - createPurchaseTx(tx: Tx, data: { - user_id: number; - prompt_id: number; - seller_id?: number; - amount: number; - is_free: false; - }) { - return tx.purchase.create({ data }); - }, - - createPaymentTx(tx: Tx, data: { - purchase_id: number; - merchant_uid: string; - pg: 'kakaopay' | 'tosspay'; - status: 'Succeed' | 'Failed' | 'Pending'; - imp_uid: string; - }) { - 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.imp_uid, - }, - }); - }, - - upsertSettlementForPaymentTx(tx: Tx, input: { - sellerId: number; - paymentId: number; - amount: number; - fee: number; - status: 'Succeed' | 'Failed' | 'Pending'; - }) { - return tx.settlement.upsert({ - where: { payment_id: input.paymentId }, - create: { - user_id: input.sellerId, - payment_id: input.paymentId, - amount: input.amount, - fee: input.fee, - status: input.status, - }, - update: { - amount: input.amount, - fee: input.fee, - status: input.status, - }, - }); - }, - - findSucceededByUser(userId: number) { - return prisma.purchase.findMany({ - where: { - user_id: userId, - payment: { is: { status: 'Succeed'}}, - }, - include: { - prompt: { - select: { - prompt_id: true, - title: true, - user: { select: { nickname: true}}, - }, - }, - payment: { - select: { - provider: true, - }, - }, - }, - orderBy: { created_at: 'desc'}, - }) } }; \ No newline at end of file diff --git a/src/purchases/routes/purchase.webhook.route.ts b/src/purchases/routes/purchase.webhook.route.ts new file mode 100644 index 0000000..c3602bf --- /dev/null +++ b/src/purchases/routes/purchase.webhook.route.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import bodyParser from 'body-parser'; +import { WebhookController } from '../controller/purchase.webhook.controller'; + +const router = Router(); + +router.post( + '/portone-webhook', + bodyParser.text({ type: 'application/json' }), + WebhookController.handleWebhook +); + +export default router; \ No newline at end of file diff --git a/src/purchases/services/purchase.complete.service.ts b/src/purchases/services/purchase.complete.service.ts index c2f03f1..608c95b 100644 --- a/src/purchases/services/purchase.complete.service.ts +++ b/src/purchases/services/purchase.complete.service.ts @@ -1,102 +1,79 @@ import { PromptPurchaseCompleteRequestDTO, PromptPurchaseCompleteResponseDTO } from '../dtos/purchase.complete.dto'; -import { PurchaseRepository } from '../repositories/purchase.request.repository'; +import { PurchaseRequestRepository } from '../repositories/purchase.request.repository'; +import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; import { AppError } from '../../errors/AppError'; import prisma from '../../config/prisma'; import { fetchAndVerifyPortonePayment } from '../utils/portone'; - -function mapPgProvider(pg: string | undefined): 'kakaopay' | 'tosspay' { - const src = (pg || '').toLowerCase(); - if (src.includes('kakaopay')) return 'kakaopay'; - if (src.includes('tosspay')) return 'tosspay'; - return 'tosspay'; -} +import { mapPgProvider } from '../utils/payment.util'; export const PurchaseCompleteService = { async completePurchase(userId: number, dto: PromptPurchaseCompleteRequestDTO): Promise { - const { imp_uid, merchant_uid } = dto; - - // (A) ๊ฒฐ์ œ ๋Œ€์ƒ ํ”„๋กฌํ”„ํŠธ/๊ฐ€๊ฒฉ ํ™•๋ณด(์„œ๋ฒ„ ๊ธฐ์ค€ ๊ธฐ๋Œ€๊ธˆ์•ก) - // - custom_data๋ฅผ ์‹ ๋ขฐํ•˜์ง€ ๋ง๊ณ , merchant_uid๋กœ ์ž์ฒด ๋งค์นญํ•˜๊ฑฐ๋‚˜, - // ๊ฒฐ์ œ ์‹œ์ž‘ ๋‹จ๊ณ„์—์„œ ์ €์žฅํ•œ ์ปจํ…์ŠคํŠธ๋ฅผ ์กฐํšŒํ•˜๋Š” ๊ฒŒ ๋ฒ ์ŠคํŠธ. - // - ์—ฌ๊ธฐ์„œ๋Š” ๊ฐ„๋‹จํžˆ PortOne.custom_data.prompt_id๋ฅผ ์“ธ ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋˜, - // ๊ธฐ๋Œ€๊ธˆ์•ก์€ ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ์—์„œ ๊ฐ€์ ธ์˜ค์ž. - // (ํ•„์š” ์‹œ PurchaseRepository์— prompt ๊ฐ€๊ฒฉ ์กฐํšŒ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ) - - // ์ž„์‹œ: ํฌํŠธ์› ๊ฒ€์ฆ ์ „์— prompt_id๋ฅผ ๋ชจ๋ฅด๋ฏ€๋กœ 1์ฐจ ์กฐํšŒ ํ›„ prompt ์žฌ์กฐํšŒ ํ”Œ๋กœ์šฐ๋กœ ์ง„ํ–‰ - - // 1) PortOne์—์„œ ๊ฒฐ์ œ ์กฐํšŒ ํ›„ ์ƒํƒœ/์ฃผ๋ฌธ๋ฒˆํ˜ธ/๊ธˆ์•ก ๊ฒ€์ฆ - // ๊ธฐ๋Œ€ ๊ธˆ์•ก์€ ์šฐ์„  0์œผ๋กœ ๋„ฃ๊ณ , ์•„๋ž˜์—์„œ prompt ๋กœ๋“œ ํ›„ ๋‹ค์‹œ ํฌ๋กœ์Šค์ฒดํฌ - const prelim = await fetchAndVerifyPortonePayment(imp_uid, { merchant_uid }); + const { paymentId } = dto; + + // 1. ํฌํŠธ์› ์กฐํšŒ (๊ฒ€์ฆ ์ „ ๋‹จ๊ณ„) + const verifiedPayment = await fetchAndVerifyPortonePayment(paymentId, { amount: -1 }); - // 2) ํ”„๋กฌํ”„ํŠธ + ํŒ๋งค์ž ์กฐํšŒ - const safeCustom = prelim.custom_data || {}; - const promptId = Number(safeCustom.prompt_id); - if (!promptId) throw new AppError('์ž˜๋ชป๋œ ๊ฒฐ์ œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค.(prompt_id ๋ˆ„๋ฝ)', 400, 'BadRequest'); + const promptId = Number(verifiedPayment.customData?.prompt_id); + if (!promptId) throw new AppError('๊ฒฐ์ œ ์ •๋ณด์— ์ƒํ’ˆ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.', 400, 'InvalidPaymentData'); - const prompt = await PurchaseRepository.findPromptWithSeller(promptId); + // 2. DB์—์„œ ์‹ค์ œ ๊ฐ€๊ฒฉ ์กฐํšŒ + const prompt = await PurchaseRequestRepository.findPromptWithSeller(promptId); if (!prompt) throw new AppError('ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 404, 'NotFound'); - if (prompt.is_free) throw new AppError('๋ฌด๋ฃŒ ํ”„๋กฌํ”„ํŠธ๋Š” ๊ฒฐ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 400, 'BadRequest'); - // ์„œ๋ฒ„ ๊ธฐ์ค€ ๊ธฐ๋Œ€๊ธˆ์•ก - const expectedAmount = Number((prompt as any).price ?? prompt.price ?? 0); - if (!expectedAmount) throw new AppError('๊ฒฐ์ œ ๊ธˆ์•ก ์‚ฐ์ • ์‹คํŒจ', 400, 'BadRequest'); - - // (์žฌ๊ฒ€์ฆ) ํฌํŠธ์› ๊ธˆ์•ก vs ์„œ๋ฒ„ ๊ธฐ๋Œ€๊ธˆ์•ก - if (Number(prelim.amount) !== expectedAmount) { - throw new AppError('๊ฒฐ์ œ ๊ธˆ์•ก ๊ฒ€์ฆ ์‹คํŒจ(์„œ๋ฒ„ ๊ธฐ๋Œ€๊ธˆ์•ก ๋ถˆ์ผ์น˜)', 400, 'BadRequest'); + // 3. ์„œ๋ฒ„๊ฐ€ ์•Œ๊ณ  ์žˆ๋Š” ๊ฐ€๊ฒฉ๊ณผ ํฌํŠธ์› ๊ฒฐ์ œ ๊ฐ€๊ฒฉ ๋น„๊ต (์ด์ค‘ ๊ฒ€์ฆ) + const serverPrice = prompt.price; + if (verifiedPayment.amount !== serverPrice) { + throw new AppError('๊ฒฐ์ œ ๊ธˆ์•ก ์œ„๋ณ€์กฐ๊ฐ€ ๊ฐ์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 400, 'FraudDetected'); } - // ์ด๋ฏธ ๊ตฌ๋งคํ–ˆ๋Š”์ง€(์„ฑ๊ณต ๊ฒฐ์ œ ์กด์žฌ) ์ฒดํฌ - const already = await PurchaseRepository.findExistingPurchase(userId, prompt.prompt_id); + // 4. ์ค‘๋ณต ๊ตฌ๋งค ์ฒดํฌ + const already = await PurchaseRequestRepository.findExistingPurchase(userId, prompt.prompt_id); if (already) { - return { - message: '์ด๋ฏธ ๊ตฌ๋งคํ•œ ํ”„๋กฌํ”„ํŠธ์ž…๋‹ˆ๋‹ค.', - status: 'Succeed', - purchase_id: already.purchase_id, - statusCode: 200, - }; + throw new AppError('์ด๋ฏธ ๊ตฌ๋งคํ•œ ํ”„๋กฌํ”„ํŠธ์ž…๋‹ˆ๋‹ค.', 409, 'AlreadyPurchased'); } - const seller_id = prompt.user_id; - const amount = expectedAmount; - const pg = mapPgProvider(prelim.pg_provider); - - // 3) ํŠธ๋žœ์žญ์…˜: Purchase โ†’ Payment โ†’ Settlement + // 5. ํŠธ๋žœ์žญ์…˜ ์ฒ˜๋ฆฌ + const pgProvider = mapPgProvider(verifiedPayment.method_provider); + const { purchase_id } = await prisma.$transaction(async (tx) => { - // ๊ตฌ๋งค ์ƒ์„ฑ - const purchase = await PurchaseRepository.createPurchaseTx(tx, { - user_id: userId, - prompt_id: prompt.prompt_id, - seller_id, - amount, - is_free: false, - }); + // ๊ตฌ๋งค ๊ธฐ๋ก ์ƒ์„ฑ + const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, { + user_id: userId, + prompt_id: prompt.prompt_id, + seller_id: prompt.user_id, + amount: serverPrice, + is_free: false + }); - // ๊ฒฐ์ œ ์ƒ์„ฑ (DB enum: 'SUCCEED'๋กœ ๊ฐ€์ •) - const payment = await PurchaseRepository.createPaymentTx(tx, { - purchase_id: purchase.purchase_id, - merchant_uid, - pg, - status: 'Succeed', - imp_uid, - }); + // ๊ฒฐ์ œ ๊ธฐ๋ก ์ƒ์„ฑ + const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { + purchase_id: purchase.purchase_id, + merchant_uid: paymentId, + pg: pgProvider, + status: 'Succeed', + paymentId: paymentId + }); + + // ์ •์‚ฐ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + const FEE_RATE = 0.1; + const fee = Math.floor(serverPrice * FEE_RATE); - // ์ •์‚ฐ upsert (๊ฒฐ์ œ ๋‹จ์œ„) - await PurchaseRepository.upsertSettlementForPaymentTx(tx, { - sellerId: seller_id, - paymentId: payment.payment_id, - amount, - fee: 0, - status: 'Succeed', - }); - return { purchase_id: purchase.purchase_id }; + await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, { + sellerId: prompt.user_id, + paymentId: payment.payment_id, + amount: serverPrice - fee, + fee: fee, + status: 'Pending' + }); + + return { purchase_id: purchase.purchase_id }; }); return { - message: '๊ฒฐ์ œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', - status: 'Succeed', - purchase_id, - statusCode: 200, + message: '๊ฒฐ์ œ ์„ฑ๊ณต', + status: 'Succeed', + purchase_id, + statusCode: 200, }; }, }; \ No newline at end of file diff --git a/src/purchases/services/purchase.request.service.ts b/src/purchases/services/purchase.request.service.ts index 6531e26..2ca3d2b 100644 --- a/src/purchases/services/purchase.request.service.ts +++ b/src/purchases/services/purchase.request.service.ts @@ -1,15 +1,15 @@ import { PromptPurchaseRequestDTO } from '../dtos/purchase.request.dto'; -import { PurchaseRepository } from '../repositories/purchase.request.repository'; +import { PurchaseRequestRepository } from '../repositories/purchase.request.repository'; import { AppError } from '../../errors/AppError'; export const PurchaseRequestService = { async createPurchaseRequest(userId: number, dto: PromptPurchaseRequestDTO) { - const prompt = await PurchaseRepository.findPromptWithSeller(dto.prompt_id); + const prompt = await PurchaseRequestRepository.findPromptWithSeller(dto.prompt_id); if (!prompt) throw new AppError('ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 404, 'NotFound'); if (prompt.is_free) throw new AppError('ํ•ด๋‹น ํ”„๋กฌํ”„ํŠธ๋Š” ๋ฌด๋ฃŒ์ž…๋‹ˆ๋‹ค.', 400, 'BadRequest'); - const existing = await PurchaseRepository.findExistingPurchase(userId, dto.prompt_id); + const existing = await PurchaseRequestRepository.findExistingPurchase(userId, dto.prompt_id); if (existing) throw new AppError('์ด๋ฏธ ๊ตฌ๋งคํ•œ ํ”„๋กฌํ”„ํŠธ์ž…๋‹ˆ๋‹ค.', 409, 'AlreadyPurchased'); return { @@ -19,6 +19,7 @@ export const PurchaseRequestService = { redirect_url: dto.redirect_url, // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋„˜๊ธด ๊ฐ’ ์‚ฌ์šฉ custom_data: { prompt_id: dto.prompt_id, + user_id: userId, }, statusCode: 200, }; diff --git a/src/purchases/services/purchase.service.ts b/src/purchases/services/purchase.service.ts index 4b6483b..11aef0e 100644 --- a/src/purchases/services/purchase.service.ts +++ b/src/purchases/services/purchase.service.ts @@ -1,12 +1,6 @@ -import { PurchaseRepository } from "../repositories/purchase.request.repository"; +import { PurchaseRepository } from "../repositories/purchase.repository"; import { PurchaseHistoryItemDTO, PurchaseHistoryResponseDTO } from "../dtos/purchase.dto"; - -function mapPgProvider(pg: string | undefined): 'kakaopay' | 'tosspay' { - const src = (pg || '').toLowerCase(); - if (src.includes('kakaopay')) return 'kakaopay'; - if (src.includes('tosspay')) return 'tosspay'; - return 'tosspay'; -} +import { mapPgProvider } from "../utils/payment.util"; export const PurchaseHistoryService = { async list(userId: number): Promise { diff --git a/src/purchases/services/purchase.webhook.service.ts b/src/purchases/services/purchase.webhook.service.ts new file mode 100644 index 0000000..bec02fa --- /dev/null +++ b/src/purchases/services/purchase.webhook.service.ts @@ -0,0 +1,88 @@ +import { PurchaseRequestRepository } from '../repositories/purchase.request.repository'; +import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; +import prisma from '../../config/prisma'; +import { fetchAndVerifyPortonePayment } from '../utils/portone'; +import { mapPgProvider } from '../utils/payment.util'; + +export const WebhookService = { + async handleTransactionPaid(paymentId: string, storeId: string) { + console.log(`[Webhook] Payment Paid Event Received: ${paymentId}`); + + // 1. ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ๊ฒฐ์ œ์ธ์ง€ ํ™•์ธ + try { + // 2. ํฌํŠธ์› ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ + const verifiedPayment = await fetchAndVerifyPortonePayment(paymentId, { amount: -1 }); + + const promptId = Number(verifiedPayment.customData?.prompt_id); + if (!promptId) { + console.error('[Webhook] Prompt ID missing in customData'); + return; // ๋ฐ์ดํ„ฐ ์˜ค๋ฅ˜์ด๋ฏ€๋กœ ์ข…๋ฃŒ (์žฌ์‹œ๋„ ๋ฐฉ์ง€ ์œ„ํ•ด 200 ๋ฆฌํ„ด ๋Œ€์ƒ) + } + + const userId = Number(verifiedPayment.customData?.user_id); + + // 3. ์ค‘๋ณต ๊ตฌ๋งค ์ฒดํฌ + if (userId) { + const existing = await PurchaseRequestRepository.findExistingPurchase(userId, promptId); + if (existing) { + console.log(`[Webhook] Already processed purchase. PaymentId: ${paymentId}`); + return; + } + } + + // 4. ๊ฐ€๊ฒฉ ๊ฒ€์ฆ + const prompt = await PurchaseRequestRepository.findPromptWithSeller(promptId); + if (!prompt) throw new Error('Prompt not found'); + + const serverPrice = prompt.price; + + if (verifiedPayment.amount !== serverPrice) { + console.error('[Webhook] Fraud detected: Amount mismatch'); + return; + } + + // 5. ํŠธ๋žœ์žญ์…˜ ์ฒ˜๋ฆฌ + if (!userId) { + console.error('[Webhook] User ID not found in custom_data. Cannot process.'); + return; + } + + const pgProvider = mapPgProvider(verifiedPayment.method_provider); + + await prisma.$transaction(async (tx) => { + // ๊ตฌ๋งค ์ƒ์„ฑ + const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, { + user_id: userId, + prompt_id: prompt.prompt_id, + seller_id: prompt.user_id, + amount: serverPrice, + is_free: false + }); + + // ๊ฒฐ์ œ ์ƒ์„ฑ + const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { + purchase_id: purchase.purchase_id, + merchant_uid: paymentId, + pg: pgProvider, + status: 'Succeed', + paymentId: paymentId + }); + + // ์ •์‚ฐ ์ƒ์„ฑ + const FEE_RATE = 0.1; + const fee = Math.floor(serverPrice * FEE_RATE); + await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, { + sellerId: prompt.user_id, + paymentId: payment.payment_id, + amount: serverPrice - fee, + fee: fee, + status: 'Pending' + }); + }); + console.log(`[Webhook] Successfully processed payment: ${paymentId}`); + } catch (error) { + console.error('[Webhook] Processing failed:', error); + throw error; + } + } +}; \ No newline at end of file diff --git a/src/purchases/utils/payment.util.ts b/src/purchases/utils/payment.util.ts new file mode 100644 index 0000000..c227fbd --- /dev/null +++ b/src/purchases/utils/payment.util.ts @@ -0,0 +1,9 @@ +import { AppError } from "../../errors/AppError"; + +export function mapPgProvider(provider: string | undefined): 'kakaopay' | 'tosspay' { + const src = (provider || '').toLowerCase(); + if (src.includes('kakao')) return 'kakaopay'; + if (src.includes('toss')) return 'tosspay'; + + throw new AppError(`์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ ์ˆ˜๋‹จ์ž…๋‹ˆ๋‹ค. (Provider: ${provider})`, 400, 'InvalidPaymentMethod'); +} \ No newline at end of file diff --git a/src/purchases/utils/portone.ts b/src/purchases/utils/portone.ts index c4c109a..5f948ed 100644 --- a/src/purchases/utils/portone.ts +++ b/src/purchases/utils/portone.ts @@ -1,125 +1,120 @@ -import axios, { AxiosError} from 'axios'; +import axios from 'axios'; import { AppError } from '../../errors/AppError'; -let cachedToken: { token: string; exp: number } | null = null; - -async function getPortoneAccessToken(): Promise { - const { PORTONE_API_KEY, PORTONE_API_SECRET } = process.env; - if (!PORTONE_API_KEY || !PORTONE_API_SECRET) { - throw new AppError('ํฌํŠธ์› API ํ‚ค/์‹œํฌ๋ฆฟ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', 500, 'ServerConfig'); - } - - if (cachedToken && cachedToken.exp > Date.now() + 20_000) { - return cachedToken.token; - } - - const { data } = await axios.post('https://api.iamport.kr/users/getToken', { - imp_key: PORTONE_API_KEY, - imp_secret: PORTONE_API_SECRET, - }); - - if (data?.code !== 0 || !data?.response?.access_token) { - throw new AppError('ํฌํŠธ์› ์•ก์„ธ์Šค ํ† ํฐ ๋ฐœ๊ธ‰ ์‹คํŒจ', 502, 'BadGateway'); - } - - const token = data.response.access_token as string; - const expiredAtSec = data.response.expired_at as number; - cachedToken = { token, exp: expiredAtSec * 1000 }; - return token; +interface PortOnePaymentResponse { + id: string; // paymentId + status: "VIRTUAL_ACCOUNT_ISSUED" | "PAID" | "FAILED" | "CANCELLED" | "PARTIAL_CANCELLED"; + amount: { + total: number; + taxFree: number; + vat: number; + paid: number; + cancelled: number; + }; + orderName: string; + method?: { + type: "CARD" | "VIRTUAL_ACCOUNT" | "EASY_PAY" | "TRANSFER" | "MOBILE"; + easyPay?: { + provider: string; + }; + card?: { + publisher: string; + }; + }; + customData?: string; + requestedAt: string; + paidAt?: string; } export type PortonePaymentVerified = { + paymentId: string; amount: number; - merchant_uid: string; - pg_provider: string; - paid_at: number; - name: string; - custom_data: any; - raw: any; + status: string; + method_provider?: string; + paidAt?: Date; + customData: any; }; export async function fetchAndVerifyPortonePayment( - imp_uid: string, - expected: { merchant_uid: string; amount?: number } + paymentId: string, + expected: { amount: number } ): Promise { - try { - const accessToken = await getPortoneAccessToken(); + const { PORTONE_API_SECRET } = process.env; - const { data } = await axios.get(`https://api.iamport.kr/payments/${imp_uid}`, { - headers: { - Authorization: `Bearer ${accessToken}`, // Bearer ํ•„์ˆ˜ - Accept: 'application/json', - }, - timeout: 10_000, - }); + if (!PORTONE_API_SECRET) { + throw new AppError('ํฌํŠธ์› API ์‹œํฌ๋ฆฟ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', 500, 'ServerConfig'); + } - if (data?.code !== 0 || !data?.response) { - throw new AppError('ํฌํŠธ์› ๊ฒฐ์ œ ์กฐํšŒ ์‹คํŒจ', 502, 'BadGateway'); + try { + // 1. ํฌํŠธ์› ๊ฒฐ์ œ ๋‹จ๊ฑด ์กฐํšŒ + const { data } = await axios.get( + `https://api.portone.io/payments/${encodeURIComponent(paymentId)}`, + { + headers: { + Authorization: `PortOne ${PORTONE_API_SECRET}`, + 'Content-Type': 'application/json', + }, + timeout: 10_000, + } + ); + + if (!data) { + throw new AppError('ํฌํŠธ์› ๊ฒฐ์ œ ์กฐํšŒ ์‘๋‹ต์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค.', 502, 'BadGateway'); } - const p = data.response; + const payment = data; - if (p.status !== 'paid') { - throw new AppError('๊ฒฐ์ œ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์™„๋ฃŒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', 400, 'BadRequest'); + // 2. ์ƒํƒœ ๊ฒ€์ฆ (PAID ์ƒํƒœ์—ฌ์•ผ ํ•จ) + if (payment.status !== 'PAID') { + throw new AppError(`๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. (Status: ${payment.status})`, 400, 'PaymentNotPaid'); } - if (typeof expected?.amount === 'number') { - if (Number(p.amount) !== Number(expected.amount)) { - throw new AppError('๊ฒฐ์ œ ๊ธˆ์•ก ๊ฒ€์ฆ ์‹คํŒจ', 400, 'BadRequest'); - } + // 3. ๊ธˆ์•ก ๊ฒ€์ฆ + if (expected.amount !== -1 && payment.amount.total !== expected.amount) { + throw new AppError('๊ฒฐ์ œ ๊ธˆ์•ก ๊ฒ€์ฆ ์‹คํŒจ (์œ„๋ณ€์กฐ ์˜์‹ฌ)', 400, 'PaymentAmountMismatch'); } - if (p.merchant_uid !== expected.merchant_uid) { - throw new AppError('์ฃผ๋ฌธ๋ฒˆํ˜ธ ๊ฒ€์ฆ ์‹คํŒจ', 400, 'BadRequest'); + // 4. Custom Data ํŒŒ์‹ฑ + let parsedCustomData: any = {}; + if (payment.customData) { + try { + parsedCustomData = JSON.parse(payment.customData); + } catch (e) { + console.warn('Custom Data Parsing Failed', payment.customData); + } } - let customData: any = p.custom_data; - if (typeof customData === 'string') { - try { customData = JSON.parse(customData); } catch {} + // 5. PG Provider ์ถ”์ถœ + let provider = ''; + if (payment.method?.type === 'EASY_PAY') { + provider = payment.method.easyPay?.provider || ''; + } else if (payment.method?.type === 'CARD') { + provider = payment.method.card?.publisher || 'CARD'; } return { - amount: Number(p.amount), - merchant_uid: p.merchant_uid, - pg_provider: p.pg_provider, - paid_at: p.paid_at, - name: p.name, - custom_data: customData, - raw: p, + paymentId: payment.id, + amount: payment.amount.total, + status: payment.status, + method_provider: provider, + paidAt: payment.paidAt ? new Date(payment.paidAt) : new Date(), + customData: parsedCustomData, }; + } catch (err: any) { - if (err instanceof AppError) { - console.error('[PortOne verify -> AppError]', { - statusCode: err.statusCode, - name: err.name, - message: err.message, - }); - throw err; - } + if (axios.isAxiosError(err)) { + const status = err.response?.status ?? 500; + const errorMsg = err.response?.data?.message || err.message; + console.error('[PortOne V2 Verify Error]', { status, errorMsg }); + + if (status === 404) throw new AppError('์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ ๋‚ด์—ญ์ž…๋‹ˆ๋‹ค.', 404, 'NotFound'); + if (status === 401) throw new AppError('ํฌํŠธ์› ์ธ์ฆ ์‹คํŒจ (API Key Check Required)', 500, 'ServerConfig'); + + throw new AppError(`ํฌํŠธ์› ๊ฒ€์ฆ ์‹คํŒจ: ${errorMsg}`, 502, 'BadGateway'); + } - // Axios ์—๋Ÿฌ ๋ถ„๊ธฐ - if (axios.isAxiosError(err)) { - const status = err.response?.status ?? 500; - const data = err.response?.data; - console.error('[PortOne verify -> AxiosError]', { - status, - data, - url: err.config?.url, - reqHeaders: err.config?.headers, - msg: err.message, - }); - const msg = - data?.message || - data?.error?.message || - err.message || - 'PortOne verify failed'; - throw new AppError(msg, status, status === 401 ? 'Unauthorized' : 'BadGateway'); - } + if (err instanceof AppError) throw err; - // ๊ทธ ์™ธ ์ผ๋ฐ˜ ์—๋Ÿฌ๋„ ๋ฉ”์‹œ์ง€/์Šคํƒ์„ ๋‚จ๊ธฐ๊ณ  ๊ทธ๋Œ€๋กœ ๋˜์ง - console.error('[PortOne verify error - non axios]', { - name: err?.name, message: err?.message, stack: err?.stack, - }); - throw err; // ํ˜น์€ throw new AppError(err?.message || 'Unknown error', 500, 'Internal'); + throw new AppError('๊ฒฐ์ œ ๊ฒ€์ฆ ์ค‘ ์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜ ๋ฐœ์ƒ', 500, 'InternalServerError'); } } \ No newline at end of file