Skip to content

Commit eb87e5b

Browse files
authored
Merge pull request #493 from PromptPlace/develop
Develop
2 parents 6ed70da + f3b33ef commit eb87e5b

6 files changed

Lines changed: 217 additions & 6 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable: 정산지급대행 빌링키 컬럼 추가 (#491)
2+
ALTER TABLE `SettlementAccount` ADD COLUMN `billing_tran_id` VARCHAR(64) NULL;

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ model SettlementAccount {
508508
company_name String? @db.VarChar(100)
509509
business_number String? @unique @db.VarChar(30)
510510
business_license_url String? @db.Text
511+
billing_tran_id String? @db.VarChar(64) // Payple 정산지급대행 계좌 빌링키 (#491)
511512
created_at DateTime @default(now())
512513
updated_at DateTime @updatedAt
513514

src/purchases/services/purchase.complete.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { PurchaseCompleteRepository } from '../repositories/purchase.complete.re
44
import { AppError } from '../../errors/AppError';
55
import prisma from '../../config/prisma';
66
import { verifyPayplePayment } from '../utils/payple';
7+
import { calculateSettlementFee } from '../utils/fee';
78

89
export const PurchaseCompleteService = {
910
async completePurchase(userId: number, dto: PurchaseCompleteRequestDTO): Promise<PurchaseCompleteResponseDTO> {
@@ -43,13 +44,12 @@ export const PurchaseCompleteService = {
4344
cash_receipt_url: verifiedPayment.cashReceiptUrl,
4445
});
4546

46-
const FEE_RATE = 0.1;
47-
const fee = Math.floor(serverPrice * FEE_RATE);
47+
const { fee, settledAmount } = calculateSettlementFee(serverPrice);
4848

4949
await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, {
5050
sellerId: prompt.user_id,
5151
paymentId: payment.payment_id,
52-
amount: serverPrice - fee,
52+
amount: settledAmount,
5353
fee,
5454
status: 'Pending',
5555
});

src/purchases/services/purchase.webhook.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repo
22
import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository';
33
import prisma from '../../config/prisma';
44
import { PayplePaymentResult, verifyPayplePayment } from '../utils/payple';
5+
import { calculateSettlementFee } from '../utils/fee';
56

67
export const WebhookService = {
78
async handlePaypleResult(result: PayplePaymentResult) {
@@ -55,12 +56,11 @@ export const WebhookService = {
5556
cash_receipt_url: verified.cashReceiptUrl,
5657
});
5758

58-
const FEE_RATE = 0.1;
59-
const fee = Math.floor(serverPrice * FEE_RATE);
59+
const { fee, settledAmount } = calculateSettlementFee(serverPrice);
6060
await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, {
6161
sellerId: prompt.user_id,
6262
paymentId: payment.payment_id,
63-
amount: serverPrice - fee,
63+
amount: settledAmount,
6464
fee,
6565
status: 'Pending',
6666
});

src/purchases/utils/fee.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// 정산 수수료 계산.
2+
// 정책 (#491): 정산금 = 판매가 - (수수료 10% + VAT 1%) = 판매가 × 89%.
3+
// 단일 fee 컬럼에 수수료+VAT 합산 저장 (회계 분리 필요 시 별도 이슈에서 VAT 컬럼 분리).
4+
export const COMMISSION_RATE = 0.1; // 수수료 10%
5+
export const VAT_RATE_ON_COMMISSION = 0.1; // VAT는 수수료의 10%
6+
export const TOTAL_DEDUCT_RATE = COMMISSION_RATE * (1 + VAT_RATE_ON_COMMISSION); // 0.11
7+
8+
export interface FeeBreakdown {
9+
fee: number; // 수수료 + VAT 합계 (Settlement.fee 컬럼에 저장)
10+
settledAmount: number; // 판매자 정산금 (Settlement.amount 컬럼)
11+
}
12+
13+
export const calculateSettlementFee = (salePrice: number): FeeBreakdown => {
14+
const fee = Math.floor(salePrice * TOTAL_DEDUCT_RATE);
15+
return {
16+
fee,
17+
settledAmount: salePrice - fee,
18+
};
19+
};
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import axios from 'axios';
2+
import redisClient from '../../config/redis';
3+
import { AppError } from '../../errors/AppError';
4+
import { redactPaypleLog } from './payple';
5+
6+
// Payple 빌링키 라이프사이클 (#491 후속 작업 대비).
7+
// 본 파일은 빌링키 조회(PUSERINFO) / 해지(PUSERDEL) 인프라만 정의.
8+
// 호출 흐름: 파트너 인증 → AuthKey/PCD_PAY_URL 수신 → 조회/해지 요청.
9+
//
10+
// 보안 정책 (#482/#485과 동일):
11+
// - Auth 캐시 TTL 25분 (Payple 30분 만료 마진)
12+
// - cstId/custKey는 캐시에서 제외하고 매 호출 env 직접 로드
13+
// - 요청/응답 로그는 redactPaypleLog로 마스킹
14+
15+
type BillingWork = 'PUSERINFO' | 'PUSERDEL';
16+
17+
const AUTH_CACHE_TTL_SECONDS = 25 * 60;
18+
19+
interface BillingAuthCache {
20+
authKey: string;
21+
payHost: string;
22+
payUrl: string;
23+
}
24+
25+
interface BillingAuth extends BillingAuthCache {
26+
cstId: string;
27+
custKey: string;
28+
}
29+
30+
const loadCredentialsFromEnv = (): { cstId: string; custKey: string } => {
31+
const cstId = process.env.PAYPLE_CST_ID;
32+
const custKey = process.env.PAYPLE_CUST_KEY;
33+
if (!cstId || !custKey) {
34+
throw new AppError('Payple 인증 설정이 누락되었습니다.', 500, 'ConfigError');
35+
}
36+
return { cstId, custKey };
37+
};
38+
39+
const getCpayBaseUrl = (): string => {
40+
const url = process.env.PAYPLE_CPAY_URL;
41+
if (!url) {
42+
throw new AppError('PAYPLE_CPAY_URL 환경변수가 설정되지 않았습니다.', 500, 'ConfigError');
43+
}
44+
return url;
45+
};
46+
47+
const getBillingAuthPath = (): string =>
48+
process.env.PAYPLE_BILLING_AUTH_PATH || '/php/auth.php';
49+
50+
const fetchBillingAuth = async (work: BillingWork): Promise<BillingAuth> => {
51+
const { cstId, custKey } = loadCredentialsFromEnv();
52+
const cacheKey = `payple:billing:auth:${work}`;
53+
54+
const cached = await redisClient.get(cacheKey);
55+
if (cached) {
56+
try {
57+
const parsed: BillingAuthCache = JSON.parse(cached);
58+
if (parsed.authKey && parsed.payHost && parsed.payUrl) {
59+
return { ...parsed, cstId, custKey };
60+
}
61+
} catch {
62+
// 캐시 손상 — 재발급
63+
}
64+
}
65+
66+
const url = `${getCpayBaseUrl()}${getBillingAuthPath()}`;
67+
const res = await axios.post(
68+
url,
69+
{ cst_id: cstId, custKey, PCD_PAY_WORK: work },
70+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
71+
);
72+
73+
if (res.data?.result !== 'success') {
74+
console.error('[payple-billing] auth failed', { work, code: res.data?.result });
75+
throw new AppError(`Payple 빌링키 ${work} 인증에 실패했습니다.`, 502, 'PaypleAuthFailed');
76+
}
77+
78+
const cacheable: BillingAuthCache = {
79+
authKey: res.data.AuthKey,
80+
payHost: res.data.PCD_PAY_HOST,
81+
payUrl: res.data.PCD_PAY_URL,
82+
};
83+
await redisClient.set(cacheKey, JSON.stringify(cacheable), { EX: AUTH_CACHE_TTL_SECONDS });
84+
return { ...cacheable, cstId, custKey };
85+
};
86+
87+
export interface BillingKeyInfo {
88+
payCode: string;
89+
payMsg: string;
90+
payType: string; // 'card' | 'transfer'
91+
payerId: string; // 조회한 빌링키
92+
payerName?: string;
93+
payerHp?: string;
94+
cardCode?: string; // PCD_PAY_CARD
95+
cardName?: string; // PCD_PAY_CARDNAME
96+
cardNumMasked?: string; // PCD_PAY_CARDNUM
97+
}
98+
99+
// 빌링키 조회 (PUSERINFO).
100+
// 카드 빌링키의 경우 마스킹된 카드번호/카드사명 반환.
101+
export const fetchBillingKeyInfo = async (payerId: string): Promise<BillingKeyInfo> => {
102+
if (!payerId) {
103+
throw new AppError('payerId(빌링키)가 누락되었습니다.', 400, 'ValidationError');
104+
}
105+
const auth = await fetchBillingAuth('PUSERINFO');
106+
107+
const url = `${auth.payHost}${auth.payUrl}`;
108+
const res = await axios.post(
109+
url,
110+
{
111+
PCD_CST_ID: auth.cstId,
112+
PCD_CUST_KEY: auth.custKey,
113+
PCD_AUTH_KEY: auth.authKey,
114+
PCD_PAYER_ID: payerId,
115+
},
116+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
117+
);
118+
119+
if (res.data?.PCD_PAY_RST !== 'success') {
120+
console.error('[payple-billing] info failed', {
121+
code: res.data?.PCD_PAY_CODE,
122+
response: redactPaypleLog(res.data),
123+
});
124+
throw new AppError(
125+
`Payple 빌링키 조회에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`,
126+
502,
127+
'PaypleBillingInfoFailed',
128+
);
129+
}
130+
131+
return {
132+
payCode: res.data.PCD_PAY_CODE,
133+
payMsg: res.data.PCD_PAY_MSG,
134+
payType: res.data.PCD_PAY_TYPE,
135+
payerId: res.data.PCD_PAYER_ID,
136+
payerName: res.data.PCD_PAYER_NAME,
137+
payerHp: res.data.PCD_PAYER_HP,
138+
cardCode: res.data.PCD_PAY_CARD,
139+
cardName: res.data.PCD_PAY_CARDNAME,
140+
cardNumMasked: res.data.PCD_PAY_CARDNUM,
141+
};
142+
};
143+
144+
export interface BillingKeyDeleteResult {
145+
payCode: string;
146+
payMsg: string;
147+
payType: string;
148+
payerId: string;
149+
}
150+
151+
// 빌링키 해지 (PUSERDEL).
152+
// 카드/계좌 빌링키를 영구 비활성화. 환불/탈퇴 흐름에서 사용.
153+
export const deleteBillingKey = async (payerId: string): Promise<BillingKeyDeleteResult> => {
154+
if (!payerId) {
155+
throw new AppError('payerId(빌링키)가 누락되었습니다.', 400, 'ValidationError');
156+
}
157+
const auth = await fetchBillingAuth('PUSERDEL');
158+
159+
const url = `${auth.payHost}${auth.payUrl}`;
160+
const res = await axios.post(
161+
url,
162+
{
163+
PCD_CST_ID: auth.cstId,
164+
PCD_CUST_KEY: auth.custKey,
165+
PCD_AUTH_KEY: auth.authKey,
166+
PCD_PAYER_ID: payerId,
167+
},
168+
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
169+
);
170+
171+
if (res.data?.PCD_PAY_RST !== 'success') {
172+
console.error('[payple-billing] delete failed', {
173+
code: res.data?.PCD_PAY_CODE,
174+
response: redactPaypleLog(res.data),
175+
});
176+
throw new AppError(
177+
`Payple 빌링키 해지에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`,
178+
502,
179+
'PaypleBillingDeleteFailed',
180+
);
181+
}
182+
183+
return {
184+
payCode: res.data.PCD_PAY_CODE,
185+
payMsg: res.data.PCD_PAY_MSG,
186+
payType: res.data.PCD_PAY_TYPE,
187+
payerId: res.data.PCD_PAYER_ID,
188+
};
189+
};

0 commit comments

Comments
 (0)