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
23 changes: 23 additions & 0 deletions src/settlements/controllers/settlement.history.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ export const getMonthlySales = async (req: Request, res: Response) => {
}
};

export const getPendingAmount = async (req: Request, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
message: '둜그인이 ν•„μš”ν•©λ‹ˆλ‹€.',
statusCode: 401,
});
}
const userId = (user as { user_id: number }).user_id;
const result = await SettlementHistoryService.getPendingAmount(userId);
return res.status(200).json(result);
} catch (error: any) {
const status = error.status || 500;
return res.status(status).json({
error: error.type || 'InternalServerError',
message: error.message || 'μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.',
statusCode: status,
});
}
};

export const getYearlySettlements = async (req: Request, res: Response) => {
try {
const user = req.user;
Expand Down
7 changes: 7 additions & 0 deletions src/settlements/dtos/settlement.history.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,10 @@ export interface YearlySettlementResponseDto {
items: YearlySettlementItemDto[];
statusCode: number;
}

export interface PendingAmountResponseDto {
message: string;
pending_amount: number;
pending_count: number;
statusCode: number;
}
15 changes: 15 additions & 0 deletions src/settlements/repositories/settlement.history.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import prisma from '../../config/prisma';
import { Status } from '@prisma/client';

export const SettlementHistoryRepository = {
// μ •μ‚° μ˜ˆμ • κΈˆμ•‘ β€” Settlement.status='Pending' 인 ν–‰μ˜ amount 합계.
// μ •μ‚° μ™„λ£Œ 처리 (Pending β†’ Succeed) 흐름이 별도 μ΄μŠˆμ—μ„œ κ΅¬ν˜„λ˜κΈ° μ „κΉŒμ§€λŠ”
// 이 값이 λͺ¨λ“  λ―Έμ •μ‚° 거래의 λˆ„κ³„κ°€ 됨.
async sumPendingAmount(userId: number): Promise<{ pending_amount: number; pending_count: number }> {
const result = await prisma.settlement.aggregate({
where: { user_id: userId, status: Status.Pending },
_sum: { amount: true },
_count: { _all: true },
});
return {
pending_amount: result._sum.amount ?? 0,
pending_count: result._count._all ?? 0,
};
},

async findSalesByMonth(userId: number, year: number, month: number) {
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 1));
Expand Down
34 changes: 33 additions & 1 deletion src/settlements/routes/settlement.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Router } from "express";
import { verifyAccount, ViewAccount, ViewAccountDetail } from "../controllers/settlement.account.controller";
import { registerIndividual, registerBusiness } from "../controllers/settlement.seller.controller";
import { uploadLicense } from "../controllers/settlement.seller.controller";
import { getMonthlySales, getYearlySettlements } from "../controllers/settlement.history.controller";
import { getMonthlySales, getYearlySettlements, getPendingAmount } from "../controllers/settlement.history.controller";
import { authenticateJwt } from "../../config/passport";

const router = Router();
Expand Down Expand Up @@ -496,6 +496,38 @@ router.post("/register/business", authenticateJwt, registerBusiness);
* 401:
* description: 둜그인 ν•„μš”
*/
/**
* @swagger
* /api/settlements/pending-amount:
* get:
* summary: μ •μ‚° μ˜ˆμ • κΈˆμ•‘ 쑰회 (λŒ€μ‹œλ³΄λ“œ)
* description: |
* λ‘œκ·ΈμΈν•œ 판맀자의 μ •μ‚° μ˜ˆμ • κΈˆμ•‘(Settlement.status='Pending'인 ν–‰μ˜ amount 합계)을 μ‘°νšŒν•©λ‹ˆλ‹€.
* 정산관리 ν™”λ©΄ 상단 λŒ€μ‹œλ³΄λ“œμ— λ…ΈμΆœ.
*
* ⚠️ ν˜„μž¬ μ½”λ“œμ—λŠ” Settlement.statusλ₯Ό 'Succeed'둜 μ—…λ°μ΄νŠΈν•˜λŠ” μ •μ‚° μ™„λ£Œ 처리 흐름이 μ—†μ–΄μ„œ,
* λͺ¨λ“  λ―Έμ •μ‚° 거래의 λˆ„κ³„κ°€ λ°˜ν™˜λ©λ‹ˆλ‹€. 별도 μ΄μŠˆμ—μ„œ Payple μ •μ‚°λ‚΄μ—­ μ‘°νšŒμ™€ μ—°λ™ν•œ 동기화 흐름이 κ΅¬ν˜„λœ λ’€μ—λŠ”
* μ‹€μ œ μ •μ‚° μ˜ˆμ •λΆ„λ§Œ λ°˜ν™˜λ©λ‹ˆλ‹€.
* tags: [Settlement]
* security:
* - jwt: []
* responses:
* 200:
* description: 쑰회 성곡
* content:
* application/json:
* schema:
* type: object
* properties:
* message: { type: string, example: μ •μ‚° μ˜ˆμ • κΈˆμ•‘ 쑰회 성곡 }
* pending_amount: { type: integer, example: 125000 }
* pending_count: { type: integer, example: 3 }
* statusCode: { type: integer, example: 200 }
* 401:
* description: 둜그인 ν•„μš”
*/
router.get("/pending-amount", authenticateJwt, getPendingAmount);

router.get("/sales/monthly", authenticateJwt, getMonthlySales);

/**
Expand Down
11 changes: 11 additions & 0 deletions src/settlements/services/settlement.history.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SettlementHistoryRepository } from '../repositories/settlement.history.
import {
MonthlySalesResponseDto,
YearlySettlementResponseDto,
PendingAmountResponseDto,
} from '../dtos/settlement.history.dto';

export const SettlementHistoryService = {
Expand Down Expand Up @@ -51,6 +52,16 @@ export const SettlementHistoryService = {
};
},

async getPendingAmount(userId: number): Promise<PendingAmountResponseDto> {
const { pending_amount, pending_count } = await SettlementHistoryRepository.sumPendingAmount(userId);
return {
message: 'μ •μ‚° μ˜ˆμ • κΈˆμ•‘ 쑰회 성곡',
pending_amount,
pending_count,
statusCode: 200,
};
},

async getYearlySettlements(userId: number): Promise<YearlySettlementResponseDto> {
const items = await SettlementHistoryRepository.aggregateYearlyTotals(userId);
return {
Expand Down
176 changes: 176 additions & 0 deletions src/settlements/utils/payple-settlement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import axios from 'axios';
import redisClient from '../../config/redis';
import { AppError } from '../../errors/AppError';

// Payple μ •μ‚°λ‚΄μ—­ 쑰회용 νŒŒνŠΈλ„ˆ 인증 + 쑰회 μœ ν‹Έ.
// μ •μ‚°λ‚΄μ—­ μ‘°νšŒλŠ” PCD_SETTLEMENT_FLAG=Y둜 인증 λ°›κ³ , μ‘λ‹΅μ˜ PCD_PAY_HOST + PCD_PAY_URL둜 호좜.
// 호좜 μ œν•œ: 1초 1회 / 2λΆ„ 20회 (ν˜ΈμΆœμžκ°€ throttling μ±…μž„).
//
// λ³Έ μ΄μŠˆμ—μ„œλŠ” μΈν”„λΌλ§Œ μΆ”κ°€. μ™ΈλΆ€ endpoint λ…ΈμΆœ μ—†μŒ. ν–₯ν›„ μ •μ‚° μ™„λ£Œ 동기화/검증/λ¦¬ν¬νŠΈμ— μž¬μ‚¬μš©.

const AUTH_CACHE_KEY = 'payple:settlement:auth';
// Payple 토큰 만료 정책이 λͺ…세상 λΆˆλΆ„λͺ… β€” μ•ˆμ „ν•˜κ²Œ 25λΆ„ μΊμ‹œ (보톡 30λΆ„ 만료 κ°€μ •)
const AUTH_CACHE_TTL_SECONDS = 25 * 60;

interface PaypleSettlementAuth {
cstId: string;
custKey: string;
authKey: string;
payHost: string;
payUrl: string;
}

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

// μ •μ‚° 인증 endpoint pathλŠ” Payple ν™˜κ²½/계약에 따라 λ‹€λ₯Ό 수 μžˆμ–΄ env둜 뢄리.
// λͺ…세상 λͺ…μ‹œλ˜μ§€ μ•Šμ•„ sandbox ν…ŒμŠ€νŠΈλ‘œ ν™•μ • ν•„μš”.
const getSettlementAuthPath = (): string =>
process.env.PAYPLE_SETTLEMENT_AUTH_PATH || '/php/auth.php';

export const fetchPaypleSettlementAuth = async (): Promise<PaypleSettlementAuth> => {
const cached = await redisClient.get(AUTH_CACHE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch {
// μΊμ‹œ 손상 β€” μž¬λ°œκΈ‰
}
}

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

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

if (res.data?.result !== 'success') {
console.error('[payple-settlement] auth failed', { code: res.data?.result });
throw new AppError('Payple μ •μ‚°λ‚΄μ—­ 쑰회 인증에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.', 502, 'PaypleAuthFailed');
}

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

export type PaypleMethod = 'CARD' | 'EASYPAY' | 'TRANSFER';
export type PaypleTxnType = 'APPROVAL' | 'CANCEL';
export type PaypleResponsePayType = 'card' | 'easypay' | 'transfer';

export interface FetchSettlementsParams {
startDate: string; // yyyy-MM-dd λ˜λŠ” yyyyMMdd
endDate: string; // μ΅œλŒ€ 31일 λ²”μœ„
method?: PaypleMethod;
limit?: number; // κΈ°λ³Έ 100, μ΅œμ†Œ 1, μ΅œλŒ€ 3000
lastKey?: string; // νŽ˜μ΄μ§€λ„€μ΄μ…˜ μ»€μ„œ
}

export interface PaypleSettlementItem {
txnType: PaypleTxnType;
payOid: string;
payTime: string;
payCancelTime: string | null;
settleDate: string;
payType: PaypleResponsePayType;
payerName: string;
payGoods: string;
payAmount: number;
feeSupply: number;
feeVat: number;
feeTotal: number;
settleAmount: number;
}

export interface PaypleSettlementsPage {
items: PaypleSettlementItem[];
hasMore: boolean;
lastKey: string | null;
totals: {
count: number;
approvalAmount: number;
cancelAmount: number;
feeAmount: number;
settleAmount: number;
};
}

export const fetchPaypleSettlements = async (
params: FetchSettlementsParams,
): Promise<PaypleSettlementsPage> => {
const auth = await fetchPaypleSettlementAuth();

const body: Record<string, unknown> = {
PCD_CST_ID: auth.cstId,
PCD_CUST_KEY: auth.custKey,
PCD_AUTH_KEY: auth.authKey,
PCD_SETTLEMENT_FLAG: 'Y',
PCD_START_DATE: params.startDate,
PCD_END_DATE: params.endDate,
};
if (params.method) body.PCD_METHOD = params.method;
if (params.limit) body.PCD_LIMIT = params.limit;
if (params.lastKey) body.PCD_LASTKEY = params.lastKey;

const url = `${auth.payHost}${auth.payUrl}`;
const res = await axios.post(url, body, {
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
});

if (res.data?.PCD_PAY_RST !== 'success') {
console.error('[payple-settlement] query failed', { code: res.data?.PCD_PAY_CODE });
throw new AppError(
`Payple μ •μ‚°λ‚΄μ—­ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`,
502,
'PaypleSettlementQueryFailed',
);
}

const content: any[] = res.data?.PCD_DATA?.PCD_CONTENT ?? [];
const totals = res.data?.PCD_DATA?.PCD_PAGE_TOTALS ?? {};

return {
items: content.map((item) => ({
txnType: item.PCD_TXN_TYPE,
payOid: item.PCD_PAY_OID,
payTime: item.PCD_PAY_TIME,
payCancelTime: item.PCD_PAY_CANCEL_TIME ?? null,
settleDate: item.PCD_SETTLE_DATE,
payType: item.PCD_PAY_TYPE,
payerName: item.PCD_PAYER_NAME,
payGoods: item.PCD_PAY_GOODS,
payAmount: item.PCD_PAY_AMOUNT,
feeSupply: item.PCD_FEE_SUPPLY,
feeVat: item.PCD_FEE_VAT,
feeTotal: item.PCD_FEE_TOTAL,
settleAmount: item.PCD_SETTLE_AMOUNT,
})),
hasMore: !!res.data?.PCD_DATA?.PCD_HAS_MORE,
lastKey: res.data?.PCD_DATA?.PCD_LASTKEY ?? null,
totals: {
count: totals.PCD_TOTAL_COUNT ?? 0,
approvalAmount: totals.PCD_TOTAL_APPROVAL_AMOUNT ?? 0,
cancelAmount: totals.PCD_TOTAL_CANCEL_AMOUNT ?? 0,
feeAmount: totals.PCD_TOTAL_FEE_AMOUNT ?? 0,
settleAmount: totals.PCD_TOTAL_SETTLE_AMOUNT ?? 0,
},
};
};
47 changes: 47 additions & 0 deletions swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -7700,6 +7700,53 @@
}
}
},
"/api/settlements/pending-amount": {
"get": {
"summary": "μ •μ‚° μ˜ˆμ • κΈˆμ•‘ 쑰회 (λŒ€μ‹œλ³΄λ“œ)",
"description": "λ‘œκ·ΈμΈν•œ 판맀자의 μ •μ‚° μ˜ˆμ • κΈˆμ•‘(Settlement.status='Pending'인 ν–‰μ˜ amount 합계)을 μ‘°νšŒν•©λ‹ˆλ‹€.\n정산관리 ν™”λ©΄ 상단 λŒ€μ‹œλ³΄λ“œμ— λ…ΈμΆœ.\n\n⚠️ ν˜„μž¬ μ½”λ“œμ—λŠ” Settlement.statusλ₯Ό 'Succeed'둜 μ—…λ°μ΄νŠΈν•˜λŠ” μ •μ‚° μ™„λ£Œ 처리 흐름이 μ—†μ–΄μ„œ,\nλͺ¨λ“  λ―Έμ •μ‚° 거래의 λˆ„κ³„κ°€ λ°˜ν™˜λ©λ‹ˆλ‹€. 별도 μ΄μŠˆμ—μ„œ Payple μ •μ‚°λ‚΄μ—­ μ‘°νšŒμ™€ μ—°λ™ν•œ 동기화 흐름이 κ΅¬ν˜„λœ λ’€μ—λŠ”\nμ‹€μ œ μ •μ‚° μ˜ˆμ •λΆ„λ§Œ λ°˜ν™˜λ©λ‹ˆλ‹€.\n",
"tags": [
"Settlement"
],
"security": [
{
"jwt": []
}
],
"responses": {
"200": {
"description": "쑰회 성곡",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "μ •μ‚° μ˜ˆμ • κΈˆμ•‘ 쑰회 성곡"
},
"pending_amount": {
"type": "integer",
"example": 125000
},
"pending_count": {
"type": "integer",
"example": 3
},
"statusCode": {
"type": "integer",
"example": 200
}
}
}
}
}
},
"401": {
"description": "둜그인 ν•„μš”"
}
}
}
},
"/api/settlements/yearly": {
"get": {
"summary": "연도별 λˆ„μ  μ •μ‚° λ‚΄μ—­ 쑰회",
Expand Down
Loading