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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable: μ •μ‚°μ§€κΈ‰λŒ€ν–‰ λΉŒλ§ν‚€ 컬럼 μΆ”κ°€ (#491)
ALTER TABLE `SettlementAccount` ADD COLUMN `billing_tran_id` VARCHAR(64) NULL;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ model SettlementAccount {
company_name String? @db.VarChar(100)
business_number String? @unique @db.VarChar(30)
business_license_url String? @db.Text
billing_tran_id String? @db.VarChar(64) // Payple μ •μ‚°μ§€κΈ‰λŒ€ν–‰ κ³„μ’Œ λΉŒλ§ν‚€ (#491)
created_at DateTime @default(now())
updated_at DateTime @updatedAt

Expand Down
6 changes: 3 additions & 3 deletions src/purchases/services/purchase.complete.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PurchaseCompleteRepository } from '../repositories/purchase.complete.re
import { AppError } from '../../errors/AppError';
import prisma from '../../config/prisma';
import { verifyPayplePayment } from '../utils/payple';
import { calculateSettlementFee } from '../utils/fee';

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

const FEE_RATE = 0.1;
const fee = Math.floor(serverPrice * FEE_RATE);
const { fee, settledAmount } = calculateSettlementFee(serverPrice);

await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, {
sellerId: prompt.user_id,
paymentId: payment.payment_id,
amount: serverPrice - fee,
amount: settledAmount,
fee,
status: 'Pending',
});
Expand Down
6 changes: 3 additions & 3 deletions src/purchases/services/purchase.webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repo
import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository';
import prisma from '../../config/prisma';
import { PayplePaymentResult, verifyPayplePayment } from '../utils/payple';
import { calculateSettlementFee } from '../utils/fee';

export const WebhookService = {
async handlePaypleResult(result: PayplePaymentResult) {
Expand Down Expand Up @@ -55,12 +56,11 @@ export const WebhookService = {
cash_receipt_url: verified.cashReceiptUrl,
});

const FEE_RATE = 0.1;
const fee = Math.floor(serverPrice * FEE_RATE);
const { fee, settledAmount } = calculateSettlementFee(serverPrice);
await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, {
sellerId: prompt.user_id,
paymentId: payment.payment_id,
amount: serverPrice - fee,
amount: settledAmount,
fee,
status: 'Pending',
});
Expand Down
19 changes: 19 additions & 0 deletions src/purchases/utils/fee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// μ •μ‚° 수수료 계산.
// μ •μ±… (#491): μ •μ‚°κΈˆ = νŒλ§€κ°€ - (수수료 10% + VAT 1%) = νŒλ§€κ°€ Γ— 89%.
// 단일 fee μ»¬λŸΌμ— 수수료+VAT ν•©μ‚° μ €μž₯ (νšŒκ³„ 뢄리 ν•„μš” μ‹œ 별도 μ΄μŠˆμ—μ„œ VAT 컬럼 뢄리).
export const COMMISSION_RATE = 0.1; // 수수료 10%
export const VAT_RATE_ON_COMMISSION = 0.1; // VATλŠ” 수수료의 10%
export const TOTAL_DEDUCT_RATE = COMMISSION_RATE * (1 + VAT_RATE_ON_COMMISSION); // 0.11

export interface FeeBreakdown {
fee: number; // 수수료 + VAT 합계 (Settlement.fee μ»¬λŸΌμ— μ €μž₯)
settledAmount: number; // 판맀자 μ •μ‚°κΈˆ (Settlement.amount 컬럼)
}

export const calculateSettlementFee = (salePrice: number): FeeBreakdown => {
const fee = Math.floor(salePrice * TOTAL_DEDUCT_RATE);
return {
fee,
settledAmount: salePrice - fee,
};
};
189 changes: 189 additions & 0 deletions src/settlements/utils/payple-billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import axios from 'axios';
import redisClient from '../../config/redis';
import { AppError } from '../../errors/AppError';
import { redactPaypleLog } from './payple';

// Payple λΉŒλ§ν‚€ 라이프사이클 (#491 후속 μž‘μ—… λŒ€λΉ„).
// λ³Έ νŒŒμΌμ€ λΉŒλ§ν‚€ 쑰회(PUSERINFO) / ν•΄μ§€(PUSERDEL) μΈν”„λΌλ§Œ μ •μ˜.
// 호좜 흐름: νŒŒνŠΈλ„ˆ 인증 β†’ AuthKey/PCD_PAY_URL μˆ˜μ‹  β†’ 쑰회/ν•΄μ§€ μš”μ²­.
//
// λ³΄μ•ˆ μ •μ±… (#482/#485κ³Ό 동일):
// - Auth μΊμ‹œ TTL 25λΆ„ (Payple 30λΆ„ 만료 λ§ˆμ§„)
// - cstId/custKeyλŠ” μΊμ‹œμ—μ„œ μ œμ™Έν•˜κ³  λ§€ 호좜 env 직접 λ‘œλ“œ
// - μš”μ²­/응닡 λ‘œκ·ΈλŠ” redactPaypleLog둜 λ§ˆμŠ€ν‚Ή

type BillingWork = 'PUSERINFO' | 'PUSERDEL';

const AUTH_CACHE_TTL_SECONDS = 25 * 60;

interface BillingAuthCache {
authKey: string;
payHost: string;
payUrl: string;
}

interface BillingAuth extends BillingAuthCache {
cstId: string;
custKey: string;
}

const loadCredentialsFromEnv = (): { cstId: string; custKey: string } => {
const cstId = process.env.PAYPLE_CST_ID;
const custKey = process.env.PAYPLE_CUST_KEY;
if (!cstId || !custKey) {
throw new AppError('Payple 인증 섀정이 λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 500, 'ConfigError');
}
return { cstId, custKey };
};

const getCpayBaseUrl = (): string => {
const url = process.env.PAYPLE_CPAY_URL;
if (!url) {
throw new AppError('PAYPLE_CPAY_URL ν™˜κ²½λ³€μˆ˜κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.', 500, 'ConfigError');
}
return url;
};

const getBillingAuthPath = (): string =>
process.env.PAYPLE_BILLING_AUTH_PATH || '/php/auth.php';

const fetchBillingAuth = async (work: BillingWork): Promise<BillingAuth> => {
const { cstId, custKey } = loadCredentialsFromEnv();
const cacheKey = `payple:billing:auth:${work}`;

const cached = await redisClient.get(cacheKey);
if (cached) {
try {
const parsed: BillingAuthCache = JSON.parse(cached);
if (parsed.authKey && parsed.payHost && parsed.payUrl) {
return { ...parsed, cstId, custKey };
}
} catch {
// μΊμ‹œ 손상 β€” μž¬λ°œκΈ‰
}
}

const url = `${getCpayBaseUrl()}${getBillingAuthPath()}`;
const res = await axios.post(
url,
{ cst_id: cstId, custKey, PCD_PAY_WORK: work },
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
);

if (res.data?.result !== 'success') {
console.error('[payple-billing] auth failed', { work, code: res.data?.result });
throw new AppError(`Payple λΉŒλ§ν‚€ ${work} 인증에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.`, 502, 'PaypleAuthFailed');
}

const cacheable: BillingAuthCache = {
authKey: res.data.AuthKey,
payHost: res.data.PCD_PAY_HOST,
payUrl: res.data.PCD_PAY_URL,
};
await redisClient.set(cacheKey, JSON.stringify(cacheable), { EX: AUTH_CACHE_TTL_SECONDS });
return { ...cacheable, cstId, custKey };
};

export interface BillingKeyInfo {
payCode: string;
payMsg: string;
payType: string; // 'card' | 'transfer'
payerId: string; // μ‘°νšŒν•œ λΉŒλ§ν‚€
payerName?: string;
payerHp?: string;
cardCode?: string; // PCD_PAY_CARD
cardName?: string; // PCD_PAY_CARDNAME
cardNumMasked?: string; // PCD_PAY_CARDNUM
}

// λΉŒλ§ν‚€ 쑰회 (PUSERINFO).
// μΉ΄λ“œ λΉŒλ§ν‚€μ˜ 경우 λ§ˆμŠ€ν‚Ήλœ μΉ΄λ“œλ²ˆν˜Έ/μΉ΄λ“œμ‚¬λͺ… λ°˜ν™˜.
export const fetchBillingKeyInfo = async (payerId: string): Promise<BillingKeyInfo> => {
if (!payerId) {
throw new AppError('payerId(λΉŒλ§ν‚€)κ°€ λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 400, 'ValidationError');
}
const auth = await fetchBillingAuth('PUSERINFO');

const url = `${auth.payHost}${auth.payUrl}`;
const res = await axios.post(
url,
{
PCD_CST_ID: auth.cstId,
PCD_CUST_KEY: auth.custKey,
PCD_AUTH_KEY: auth.authKey,
PCD_PAYER_ID: payerId,
},
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
);

if (res.data?.PCD_PAY_RST !== 'success') {
console.error('[payple-billing] info failed', {
code: res.data?.PCD_PAY_CODE,
response: redactPaypleLog(res.data),
});
throw new AppError(
`Payple λΉŒλ§ν‚€ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`,
502,
'PaypleBillingInfoFailed',
);
}

return {
payCode: res.data.PCD_PAY_CODE,
payMsg: res.data.PCD_PAY_MSG,
payType: res.data.PCD_PAY_TYPE,
payerId: res.data.PCD_PAYER_ID,
payerName: res.data.PCD_PAYER_NAME,
payerHp: res.data.PCD_PAYER_HP,
cardCode: res.data.PCD_PAY_CARD,
cardName: res.data.PCD_PAY_CARDNAME,
cardNumMasked: res.data.PCD_PAY_CARDNUM,
};
};

export interface BillingKeyDeleteResult {
payCode: string;
payMsg: string;
payType: string;
payerId: string;
}

// λΉŒλ§ν‚€ ν•΄μ§€ (PUSERDEL).
// μΉ΄λ“œ/κ³„μ’Œ λΉŒλ§ν‚€λ₯Ό 영ꡬ λΉ„ν™œμ„±ν™”. ν™˜λΆˆ/νƒˆν‡΄ νλ¦„μ—μ„œ μ‚¬μš©.
export const deleteBillingKey = async (payerId: string): Promise<BillingKeyDeleteResult> => {
if (!payerId) {
throw new AppError('payerId(λΉŒλ§ν‚€)κ°€ λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 400, 'ValidationError');
}
const auth = await fetchBillingAuth('PUSERDEL');

const url = `${auth.payHost}${auth.payUrl}`;
const res = await axios.post(
url,
{
PCD_CST_ID: auth.cstId,
PCD_CUST_KEY: auth.custKey,
PCD_AUTH_KEY: auth.authKey,
PCD_PAYER_ID: payerId,
},
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
);

if (res.data?.PCD_PAY_RST !== 'success') {
console.error('[payple-billing] delete failed', {
code: res.data?.PCD_PAY_CODE,
response: redactPaypleLog(res.data),
});
throw new AppError(
`Payple λΉŒλ§ν‚€ 해지에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`,
502,
'PaypleBillingDeleteFailed',
);
}

return {
payCode: res.data.PCD_PAY_CODE,
payMsg: res.data.PCD_PAY_MSG,
payType: res.data.PCD_PAY_TYPE,
payerId: res.data.PCD_PAYER_ID,
};
};
Loading