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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import passwordRouter from "./password/routes/password.route";
import chatRouter from "./chat/routes/chat.route";
import { initSocket } from "./socket/server";
import { startPromptStatSnapshotJob } from "./stats/jobs/prompt-stat-snapshot.job";
import { startSettlementSyncJob } from "./settlements/jobs/settlement-sync.job";
import morgan = require('morgan');
const PORT = 3000;
const app = express();
Expand All @@ -47,6 +48,7 @@ const server = http.createServer(app);

initSocket(server)
startPromptStatSnapshotJob();
startSettlementSyncJob();
// 1. 응닡 ν•Έλ“€λŸ¬(json νŒŒμ„œλ³΄λ‹€ μœ„μ—)
app.use(responseHandler);
app.use((req, res, next) => {
Expand Down
26 changes: 26 additions & 0 deletions src/settlements/jobs/settlement-sync.job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import cron from 'node-cron';
import { runSettlementSyncJob } from '../services/settlement-sync.service';

// KST 08:00에 μ–΄μ œ 정산일 κΈ°μ€€ 동기화.
// Payple μ •μ‚° ν™•μ • μ‹œκ°μ€ λͺ…세상 λΆˆλΆ„λͺ… β€” sandbox 검증 ν›„ cron ν‘œν˜„μ‹ μ‘°μ • κ°€λŠ₯.
const CRON_EXPRESSION = '0 8 * * *';
const TIMEZONE = 'Asia/Seoul';

export const startSettlementSyncJob = (): void => {
cron.schedule(
CRON_EXPRESSION,
async () => {
try {
await runSettlementSyncJob();
} catch (error) {
console.error('[settlement-sync-cron] scheduled run failed', error);
}
},
{ timezone: TIMEZONE },
);

console.log('[settlement-sync-cron] scheduled', {
cron: CRON_EXPRESSION,
timezone: TIMEZONE,
});
};
227 changes: 227 additions & 0 deletions src/settlements/services/settlement-sync.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import prisma from '../../config/prisma';
import redisClient from '../../config/redis';
import {
fetchPaypleSettlements,
PaypleSettlementItem,
} from '../utils/payple-settlement';

// μ •μ‚° μ™„λ£Œ 동기화 작 β€” Payple μ •μ‚°λ‚΄μ—­ 쑰회 κ²°κ³Όλ₯Ό 우리 Settlement에 반영.
//
// μ •μ±… (#482 ν™•μ • 사항):
// - APPROVAL만 처리. CANCEL은 skip + WARN (ν™˜λΆˆμ€ 별도 이슈)
// - κΈˆμ•‘ 검증: PCD_SETTLE_AMOUNT === Settlement.amount일 λ•Œλ§Œ Succeed 전이
// - λ©±λ“± κ°€λ“œ: WHERE status='Pending' updateMany β€” μž¬μ‹€ν–‰ μ•ˆμ „
// - λΆ„μ‚° 락: Redis SET NX EX 600 β€” μ»¨ν…Œμ΄λ„ˆ 쀑볡 μ‹€ν–‰ λ°©μ§€
// - νŽ˜μ΄μ§€ 사이 1.1초 sleep (Payple 1초 1회 ν•œλ„)
// - νŽ˜μ΄μ§€ ν•œλ„ 도달 μ‹œ lastKey μ˜μ†ν™” β†’ λ‹€μŒ cron이 이어받기

const SYNC_LOCK_KEY = 'payple:settlement:sync:lock';
const SYNC_LOCK_TTL_SECONDS = 600;
const LAST_KEY_PREFIX = 'payple:settlement:sync:lastkey';
const LAST_KEY_TTL_SECONDS = 24 * 60 * 60;
const RATE_LIMIT_SLEEP_MS = 1100;
const DEFAULT_MAX_PAGES_PER_RUN = 100;
const DEFAULT_PAGE_LIMIT = 3000;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const formatKstDate = (date: Date): string => {
const kstOffsetMs = 9 * 60 * 60 * 1000;
const kst = new Date(date.getTime() + kstOffsetMs);
const yyyy = kst.getUTCFullYear();
const mm = String(kst.getUTCMonth() + 1).padStart(2, '0');
const dd = String(kst.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
};

const getYesterdayKst = (now: Date = new Date()): string => {
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
return formatKstDate(yesterday);
};

interface SyncCounters {
date: string;
pages: number;
processed: number;
updated: number;
skipped: number;
missing: number;
cancel_skipped: number;
discrepancy: number;
failed: number;
}

const newCounters = (date: string): SyncCounters => ({
date,
pages: 0,
processed: 0,
updated: 0,
skipped: 0,
missing: 0,
cancel_skipped: 0,
discrepancy: 0,
failed: 0,
});

// APPROVAL ν•œ 건 처리. countersλŠ” mutable.
const processApproval = async (item: PaypleSettlementItem, counters: SyncCounters): Promise<void> => {
const payment = await prisma.payment.findUnique({
where: { pcd_pay_oid: item.payOid },
select: { payment_id: true, settlement: { select: { amount: true, status: true } } },
});

if (!payment) {
counters.missing += 1;
console.warn('[settlement-sync] missing payment for OID', { payOid: item.payOid });
return;
}
if (!payment.settlement) {
counters.missing += 1;
console.warn('[settlement-sync] missing settlement for payment', {
payOid: item.payOid,
paymentId: payment.payment_id,
});
return;
}
if (payment.settlement.amount !== item.settleAmount) {
counters.discrepancy += 1;
console.error('[settlement-sync] amount discrepancy β€” manual review required', {
payOid: item.payOid,
paymentId: payment.payment_id,
ourAmount: payment.settlement.amount,
paypleAmount: item.settleAmount,
});
return;
}

// λ©±λ“± κ°€λ“œ: Pending ν–‰λ§Œ Succeed둜
const result = await prisma.settlement.updateMany({
where: { payment_id: payment.payment_id, status: 'Pending' },
data: { status: 'Succeed' },
});

if (result.count > 0) {
counters.updated += 1;
} else {
counters.skipped += 1;
}
};

interface RunOptions {
date?: string;
maxPagesPerRun?: number;
pageLimit?: number;
}

// 단일 λ‚ μ§œμ— λŒ€ν•œ 동기화 1회 μ‹€ν–‰.
// λ³Έ ν•¨μˆ˜λŠ” λΆ„μ‚° 락 μ•ˆμ—μ„œλ§Œ ν˜ΈμΆœλ˜μ–΄μ•Ό ν•œλ‹€.
export const runSettlementSyncForDate = async (
options: RunOptions = {},
): Promise<SyncCounters> => {
const date = options.date ?? getYesterdayKst();
const maxPages = options.maxPagesPerRun ?? DEFAULT_MAX_PAGES_PER_RUN;
const pageLimit = options.pageLimit ?? DEFAULT_PAGE_LIMIT;
const counters = newCounters(date);
const lastKeyKey = `${LAST_KEY_PREFIX}:${date}`;

let lastKey = (await redisClient.get(lastKeyKey)) ?? undefined;

for (let page = 0; page < maxPages; page += 1) {
if (page > 0) await sleep(RATE_LIMIT_SLEEP_MS);

let response;
try {
response = await fetchPaypleSettlements({
startDate: date,
endDate: date,
limit: pageLimit,
lastKey: lastKey,
});
} catch (err: any) {
counters.failed += 1;
console.error('[settlement-sync] page fetch failed', {
date,
page,
error: err?.message,
});
// νŽ˜μ΄μ§€ μ‹€νŒ¨ μ‹œ lastKey 보쑴 (λ‹€μŒ cron이 μ΄μ–΄λ°›μŒ) ν›„ μ’…λ£Œ
break;
}

counters.pages += 1;

for (const item of response.items) {
counters.processed += 1;
try {
if (item.txnType === 'CANCEL') {
counters.cancel_skipped += 1;
console.warn('[settlement-sync] CANCEL skipped (handled in separate flow)', {
payOid: item.payOid,
});
continue;
}
if (item.txnType === 'APPROVAL') {
await processApproval(item, counters);
continue;
}
// μ•Œ 수 μ—†λŠ” txnType
counters.failed += 1;
console.error('[settlement-sync] unknown txnType', {
payOid: item.payOid,
txnType: item.txnType,
});
} catch (err: any) {
counters.failed += 1;
console.error('[settlement-sync] item processing failed', {
payOid: item.payOid,
error: err?.message,
});
}
}

if (!response.hasMore || !response.lastKey) {
// μ™„λ£Œ β€” lastKey μΊμ‹œ 제거
await redisClient.del(lastKeyKey);
lastKey = undefined;
break;
}

lastKey = response.lastKey;
await redisClient.set(lastKeyKey, lastKey, { EX: LAST_KEY_TTL_SECONDS });
}

return counters;
};

// cron μ§„μž…μ  β€” λΆ„μ‚° 락 + ν™˜κ²½ ν† κΈ€ + κ²°κ³Ό 둜그.
export const runSettlementSyncJob = async (): Promise<void> => {
if (process.env.PAYPLE_SETTLEMENT_SYNC_ENABLED === 'false') {
console.log('[settlement-sync] disabled by env');
return;
}

const lockAcquired = await redisClient.set(SYNC_LOCK_KEY, '1', {
NX: true,
EX: SYNC_LOCK_TTL_SECONDS,
});
if (lockAcquired !== 'OK') {
console.warn('[settlement-sync] lock held by another instance β€” skip this run');
return;
}

const startedAt = Date.now();
try {
const maxPages = process.env.PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN
? Number(process.env.PAYPLE_SETTLEMENT_SYNC_MAX_PAGES_PER_RUN)
: undefined;
const counters = await runSettlementSyncForDate({ maxPagesPerRun: maxPages });
console.log('[settlement-sync] completed', {
...counters,
elapsedMs: Date.now() - startedAt,
});
} catch (err: any) {
console.error('[settlement-sync] job failed', { error: err?.message });
} finally {
await redisClient.del(SYNC_LOCK_KEY);
}
};
57 changes: 37 additions & 20 deletions src/settlements/utils/payple-settlement.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import axios from 'axios';
import redisClient from '../../config/redis';
import { AppError } from '../../errors/AppError';
import { redactPaypleLog } from './payple';

// Payple μ •μ‚°λ‚΄μ—­ 쑰회용 νŒŒνŠΈλ„ˆ 인증 + 쑰회 μœ ν‹Έ.
// μ •μ‚°λ‚΄μ—­ μ‘°νšŒλŠ” PCD_SETTLEMENT_FLAG=Y둜 인증 λ°›κ³ , μ‘λ‹΅μ˜ PCD_PAY_HOST + PCD_PAY_URL둜 호좜.
// 호좜 μ œν•œ: 1초 1회 / 2λΆ„ 20회 (ν˜ΈμΆœμžκ°€ throttling μ±…μž„).
//
// λ³Έ μ΄μŠˆμ—μ„œλŠ” μΈν”„λΌλ§Œ μΆ”κ°€. μ™ΈλΆ€ endpoint λ…ΈμΆœ μ—†μŒ. ν–₯ν›„ μ •μ‚° μ™„λ£Œ 동기화/검증/λ¦¬ν¬νŠΈμ— μž¬μ‚¬μš©.
// λ³΄μ•ˆ μ •μ±… (#482 보강):
// - Auth μΊμ‹œ TTL을 15λΆ„μœΌλ‘œ 단좕 (Payple 토큰 만료 μΆ”μ •μΉ˜λ³΄λ‹€ 짧게)
// - cstId/custKeyλŠ” μΊμ‹œμ—μ„œ μ œμ™Έν•˜κ³  λ§€ 호좜 μ‹œ envμ—μ„œ 직접 λ‘œλ“œ (μΊμ‹œ 손상 μ‹œ μ˜μ—­ μΆ•μ†Œ)
// - μš”μ²­/응닡 λ‘œκ·ΈλŠ” redactPaypleLog둜 λ§ˆμŠ€ν‚Ή

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

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

export interface PaypleSettlementAuth extends PaypleSettlementAuthCache {
cstId: string;
custKey: string;
}

const getCpayBaseUrl = (): string => {
const url = process.env.PAYPLE_CPAY_URL;
if (!url) {
Expand All @@ -33,26 +39,35 @@ const getCpayBaseUrl = (): string => {
const getSettlementAuthPath = (): string =>
process.env.PAYPLE_SETTLEMENT_AUTH_PATH || '/php/auth.php';

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 };
};

export const fetchPaypleSettlementAuth = async (): Promise<PaypleSettlementAuth> => {
// 자격증λͺ…은 λ§€ 호좜 envμ—μ„œ (μΊμ‹œ λ…ΈμΆœ μ˜μ—­ μΆ•μ†Œ)
const { cstId, custKey } = loadCredentialsFromEnv();

const cached = await redisClient.get(AUTH_CACHE_KEY);
if (cached) {
try {
return JSON.parse(cached);
const parsed: PaypleSettlementAuthCache = JSON.parse(cached);
if (parsed.authKey && parsed.payHost && parsed.payUrl) {
return { ...parsed, cstId, custKey };
}
} 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' },
{ cst_id: cstId, custKey, PCD_SETTLEMENT_FLAG: 'Y' },
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } },
);

Expand All @@ -61,15 +76,14 @@ export const fetchPaypleSettlementAuth = async (): Promise<PaypleSettlementAuth>
throw new AppError('Payple μ •μ‚°λ‚΄μ—­ 쑰회 인증에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.', 502, 'PaypleAuthFailed');
}

const auth: PaypleSettlementAuth = {
cstId: res.data.cst_id,
custKey: res.data.custKey,
// μΊμ‹œμ—λŠ” authKey/payHost/payUrl만 μ €μž₯ (cstId/custKey λ…ΈμΆœ μ˜μ—­ μ΅œμ†Œν™”)
const cacheable: PaypleSettlementAuthCache = {
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;
await redisClient.set(AUTH_CACHE_KEY, JSON.stringify(cacheable), { EX: AUTH_CACHE_TTL_SECONDS });
return { ...cacheable, cstId, custKey };
};

export type PaypleMethod = 'CARD' | 'EASYPAY' | 'TRANSFER';
Expand Down Expand Up @@ -136,7 +150,10 @@ export const fetchPaypleSettlements = async (
});

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