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,27 @@
-- CreateTable: SettlementPayout (#491 PR D)
CREATE TABLE `SettlementPayout` (
`payout_id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`cycle_start` DATETIME(3) NOT NULL,
`cycle_end` DATETIME(3) NOT NULL,
`amount_gross` INTEGER NOT NULL DEFAULT 0,
`amount_refund` INTEGER NOT NULL DEFAULT 0,
`carry_over_prev` INTEGER NOT NULL DEFAULT 0,
`amount_net` INTEGER NOT NULL,
`billing_tran_id` VARCHAR(64) NULL,
`group_key` VARCHAR(64) NULL,
`api_tran_id` VARCHAR(64) NULL,
`status` ENUM('Pending','Succeed','Failed','Skipped') NOT NULL DEFAULT 'Pending',
`reason` VARCHAR(200) NULL,
`requested_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`completed_at` DATETIME(3) NULL,

UNIQUE INDEX `SettlementPayout_user_id_cycle_start_key`(`user_id`, `cycle_start`),
INDEX `SettlementPayout_status_idx`(`status`),
INDEX `SettlementPayout_cycle_start_idx`(`cycle_start`),
PRIMARY KEY (`payout_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- AddForeignKey
ALTER TABLE `SettlementPayout` ADD CONSTRAINT `SettlementPayout_user_id_fkey`
FOREIGN KEY (`user_id`) REFERENCES `User`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE;
35 changes: 35 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ model User {
settlementAccount SettlementAccount?
consents UserConsent[]
refunds Refund[]
payouts SettlementPayout[]
chatRoomAs1 ChatRoom[] @relation("ChatUser1")
chatRoomAs2 ChatRoom[] @relation("ChatUser2")
sentChatMessages ChatMessage[] @relation("UserSentMessages")
Expand Down Expand Up @@ -519,6 +520,40 @@ model SettlementAccount {
@@index([status])
}

// 정산 사이클별 지급이체 이력 (#491 PR D).
// 매월 15일 cron이 판매자별 net 금액(Succeed 합계 - Refunded 차감 - 이월)을 계산해 row 생성.
// 최소 금액 미달 시 Skipped, Payple 이체 성공 시 Succeed, 실패 시 Failed.
enum PayoutStatus {
Pending
Succeed
Failed
Skipped // 최소 금액 미달 또는 빌링키 미보유 등으로 자동 skip
}

model SettlementPayout {
payout_id Int @id @default(autoincrement())
user_id Int
cycle_start DateTime
cycle_end DateTime
amount_gross Int @default(0) // Succeed Settlement 합계 (사이클 범위 내)
amount_refund Int @default(0) // Refunded Settlement 합계
carry_over_prev Int @default(0) // 이전 사이클에서 이월된 음수 잔액 (절댓값)
amount_net Int // gross - refund - carry_over_prev (최종 지급액, 음수 가능)
billing_tran_id String? @db.VarChar(64) // 시점 빌링키 (SettlementAccount에서 스냅샷)
group_key String? @db.VarChar(64) // Payple 이체대기 요청 응답
api_tran_id String? @db.VarChar(64) // Payple webhook의 이체 완료 키
status PayoutStatus @default(Pending)
reason String? @db.VarChar(200) // Skipped/Failed 사유
requested_at DateTime @default(now())
completed_at DateTime?

user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade)

@@unique([user_id, cycle_start]) // 한 사이클에 한 row만
@@index([status])
@@index([cycle_start])
}

model Review {
review_id Int @id @default(autoincrement())
rating Float
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import promptRoutes from "./prompts/routes/prompt.route"; // 프롬프트 관련
import ReviewRouter from "./reviews/routes/review.route";
import purchaseRouter from "./purchases/routes/purchase.route";
import refundRouter from "./refunds/routes/refund.route";
import payoutWebhookRouter from "./settlements/routes/payout-webhook.route";
import purchaseWebhookRouter from "./purchases/routes/purchase.webhook.route";
import settlementRouter from "./settlements/routes/settlement.route";
import withdrawalRouter from "./withdrawals/routes/withdrawal.route";
Expand All @@ -40,6 +41,7 @@ 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 { startSettlementPayoutJob } from "./settlements/jobs/settlement-payout.job";
import morgan = require('morgan');
const PORT = 3000;
const app = express();
Expand All @@ -50,6 +52,7 @@ const server = http.createServer(app);
initSocket(server)
startPromptStatSnapshotJob();
startSettlementSyncJob();
startSettlementPayoutJob();
// 1. 응답 핸들러(json 파서보다 위에)
app.use(responseHandler);
app.use((req, res, next) => {
Expand Down Expand Up @@ -177,6 +180,7 @@ app.use("/api/tips", tipRouter);
// 정산 라우터
app.use("/api/settlements", settlementRouter);
app.use("/api/settlements", withdrawalRouter);
app.use("/api/payouts", payoutWebhookRouter);

//공지사항 라우터
app.use("/api/announcements", announcementRouter);
Expand Down
43 changes: 43 additions & 0 deletions src/settlements/controllers/payout-webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Request, Response } from 'express';
import { SettlementPayoutRepository } from '../repositories/settlement-payout.repository';
import { redactPaypleLog } from '../utils/payple';

// Payple 정산지급대행 이체 결과 webhook 수신 (#491 PR D).
// 명세상 인증 방식 미명시 — 운영 전 IP allowlist 또는 shared-secret 별도 적용 권장.
// 본 컨트롤러는 group_key로 SettlementPayout을 찾아 status를 Succeed/Failed로 마감.
// 멱등성: 이미 Pending이 아니면 200으로 응답하고 변경 안 함.

export const handlePayoutWebhook = async (req: Request, res: Response) => {
const body = req.body ?? {};
const result = body.result as string | undefined;
const groupKey = body.group_key as string | undefined;
const apiTranId = body.api_tran_id as string | undefined;
const message = body.message as string | undefined;

if (!groupKey) {
console.error('[payout-webhook] missing group_key', { body: redactPaypleLog(body) });
return res.status(400).json({ error: 'BadRequest', message: 'group_key 누락', statusCode: 400 });
}

const payout = await SettlementPayoutRepository.findPayoutByGroupKey(groupKey);
if (!payout) {
// 멱등 — 이미 마감됐거나 매칭 없음. 200으로 응답해 Payple 재전송 방지.
console.warn('[payout-webhook] no Pending payout for group_key', { groupKey });
return res.status(200).json({ ok: true });
}

try {
if (result === 'A0000') {
await SettlementPayoutRepository.markSucceeded(payout.payout_id, apiTranId ?? '');
console.log('[payout-webhook] payout succeeded', { payoutId: payout.payout_id, apiTranId });
} else {
const reason = `Payple webhook ${result ?? 'UNKNOWN'} - ${message ?? ''}`;
await SettlementPayoutRepository.markFailed(payout.payout_id, reason);
console.error('[payout-webhook] payout failed', { payoutId: payout.payout_id, reason });
}
return res.status(200).json({ ok: true });
} catch (err: any) {
console.error('[payout-webhook] update failed', { error: err?.message });
return res.status(500).json({ error: 'InternalServerError', statusCode: 500 });
}
};
26 changes: 26 additions & 0 deletions src/settlements/jobs/settlement-payout.job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import cron from 'node-cron';
import { runPayoutCycle } from '../services/settlement-payout.service';

// 매월 15일 KST 09:00 — 정산 사이클 (#491 PR D).
// 안내 사항: "정산일은 매월 15일이며, 이전 한 달 동안의 수익이 정산됩니다."
const CRON_EXPRESSION = '0 9 15 * *';
const TIMEZONE = 'Asia/Seoul';

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

console.log('[payout-cron] scheduled', {
cron: CRON_EXPRESSION,
timezone: TIMEZONE,
});
};
113 changes: 113 additions & 0 deletions src/settlements/repositories/settlement-payout.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import prisma from '../../config/prisma';
import { PayoutStatus, Status } from '@prisma/client';

export const SettlementPayoutRepository = {
// 활성 판매자(승인 + is_active + 빌링키 보유) 목록.
async findEligibleSellers() {
return prisma.settlementAccount.findMany({
where: {
status: 'APPROVED',
is_active: true,
billing_tran_id: { not: null },
},
select: {
user_id: true,
billing_tran_id: true,
},
});
},

// 직전 사이클 payout의 net 음수 이월액(절댓값). 없으면 0.
async getCarryOverFromLastPayout(userId: number): Promise<number> {
const last = await prisma.settlementPayout.findFirst({
where: { user_id: userId },
orderBy: { cycle_start: 'desc' },
select: { amount_net: true },
});
if (!last) return 0;
return last.amount_net < 0 ? Math.abs(last.amount_net) : 0;
},

// 사이클 범위 내 gross(Succeed) / refund(Refunded) 집계.
async aggregateUnsettledForCycle(
userId: number,
cycleStart: Date,
cycleEnd: Date,
): Promise<{ gross: number; refund: number }> {
const rows = await prisma.$queryRaw<
Array<{ gross: bigint | null; refund: bigint | null }>
>`
SELECT
SUM(CASE WHEN status = ${Status.Succeed} THEN amount ELSE 0 END) AS gross,
SUM(CASE WHEN status = ${Status.Refunded} THEN amount ELSE 0 END) AS refund
FROM Settlement
WHERE user_id = ${userId}
AND updated_at >= ${cycleStart}
AND updated_at < ${cycleEnd}
`;
const r = rows[0] ?? {};
return {
gross: Number(r.gross ?? 0),
refund: Number(r.refund ?? 0),
};
},

// 멱등성: 같은 user_id + cycle_start로 한 번만 생성.
async createPendingPayout(input: {
userId: number;
cycleStart: Date;
cycleEnd: Date;
amountGross: number;
amountRefund: number;
carryOverPrev: number;
amountNet: number;
billingTranId: string | null;
status: PayoutStatus;
reason?: string | null;
}) {
return prisma.settlementPayout.upsert({
where: { user_id_cycle_start: { user_id: input.userId, cycle_start: input.cycleStart } },
update: {}, // 이미 있으면 그대로 — 멱등성
create: {
user_id: input.userId,
cycle_start: input.cycleStart,
cycle_end: input.cycleEnd,
amount_gross: input.amountGross,
amount_refund: input.amountRefund,
carry_over_prev: input.carryOverPrev,
amount_net: input.amountNet,
billing_tran_id: input.billingTranId,
status: input.status,
reason: input.reason ?? null,
},
});
},

async updatePaypleGroupKey(payoutId: number, groupKey: string) {
return prisma.settlementPayout.update({
where: { payout_id: payoutId },
data: { group_key: groupKey },
});
},

async markFailed(payoutId: number, reason: string) {
return prisma.settlementPayout.update({
where: { payout_id: payoutId },
data: { status: 'Failed', reason, completed_at: new Date() },
});
},

// Webhook 수신용 — group_key 또는 billing_tran_id로 매칭.
async findPayoutByGroupKey(groupKey: string) {
return prisma.settlementPayout.findFirst({
where: { group_key: groupKey, status: 'Pending' },
});
},

async markSucceeded(payoutId: number, apiTranId: string) {
return prisma.settlementPayout.update({
where: { payout_id: payoutId },
data: { status: 'Succeed', api_tran_id: apiTranId, completed_at: new Date() },
});
},
};
7 changes: 7 additions & 0 deletions src/settlements/repositories/settlement.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface UpsertIndividualAccountInput {
accountNumber: string;
holderName: string;
birthDate: string; // INDIVIDUAL은 Payple 인증에 birthDate 필수
billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491)
}

export interface CreateBusinessAccountInput {
Expand All @@ -19,6 +20,7 @@ export interface CreateBusinessAccountInput {
companyName: string;
businessLicenseUrl: string;
birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 (대표자 생년월일). CORPORATE는 undefined
billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491)
}

export interface UpdateBusinessAccountInput {
Expand All @@ -32,6 +34,7 @@ export interface UpdateBusinessAccountInput {
// optional — 빈 값이면 기존 URL 유지
businessLicenseUrl?: string | null;
birthDate?: string; // BUSINESS+PERSONAL일 때만 존재
billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491)
}

export const SettlementRepository = {
Expand All @@ -46,6 +49,7 @@ export const SettlementRepository = {
account_number: dto.accountNumber,
account_holder: dto.holderName,
birth_date: dto.birthDate,
billing_tran_id: dto.billingTranId ?? null,
seller_type: 'INDIVIDUAL',
business_type: null,
status: 'APPROVED',
Expand All @@ -61,6 +65,7 @@ export const SettlementRepository = {
account_number: dto.accountNumber,
account_holder: dto.holderName,
birth_date: dto.birthDate,
billing_tran_id: dto.billingTranId ?? null,
seller_type: 'INDIVIDUAL',
status: 'APPROVED',
is_active: true,
Expand Down Expand Up @@ -93,6 +98,7 @@ export const SettlementRepository = {
representative_name: dto.representativeName,
business_license_url: dto.businessLicenseUrl,
birth_date: dto.birthDate ?? null,
billing_tran_id: dto.billingTranId ?? null,
seller_type: 'BUSINESS',
status: 'PENDING',
is_active: false,
Expand All @@ -116,6 +122,7 @@ export const SettlementRepository = {
company_name: dto.companyName,
representative_name: dto.representativeName,
birth_date: dto.birthDate ?? null,
billing_tran_id: dto.billingTranId ?? null,
seller_type: 'BUSINESS',
status: 'PENDING',
is_active: false,
Expand Down
41 changes: 41 additions & 0 deletions src/settlements/routes/payout-webhook.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Router } from 'express';
import { handlePayoutWebhook } from '../controllers/payout-webhook.controller';

const router = Router();

/**
* @swagger
* /api/payouts/webhook:
* post:
* summary: Payple 정산지급대행 이체 결과 webhook 수신
* description: |
* executePayout(NOW) 요청 시 첨부한 webhook_url로 Payple이 이체 결과를 전송.
* group_key로 SettlementPayout을 찾아 status를 Succeed/Failed로 마감.
* 멱등: 이미 Pending이 아니면 200 OK 응답만 (재전송 방지).
*
* 운영 전 IP allowlist 또는 shared-secret 인증 별도 적용 권장 (Payple 명세 미명시).
* tags: [Settlement]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* result: { type: string, description: A0000 성공 / 그 외 실패 }
* message: { type: string }
* group_key: { type: string }
* billing_tran_id: { type: string }
* api_tran_id: { type: string }
* tran_amt: { type: string }
* responses:
* 200:
* description: 멱등 처리 완료
* 400:
* description: group_key 누락
* 500:
* description: 서버 오류
*/
router.post('/webhook', handlePayoutWebhook);

export default router;
Loading
Loading