이 글은 누구를 위한 것인가
- 판매자 정산 시스템을 구축하려는 팀
- 수수료 계산과 반품 조정 로직을 설계하는 개발자
- 정산 주기별 배치와 세금계산서 발행을 자동화하려는 팀
들어가며
마켓플레이스 정산은 "판매액 - 수수료 - 반품 조정 = 지급액"이 기본이지만, 카테고리별 수수료율, 에스크로 홀드 기간, 반품/환불 클레임백이 복잡성을 더한다. 원장(ledger) 방식으로 모든 변동을 기록하면 정산 불일치를 추적할 수 있다.
이 글은 bluefoxdev.kr의 마켓플레이스 정산 시스템 가이드 를 참고하여 작성했습니다.
1. 정산 시스템 설계
[정산 원장 (Ledger) 구조]
모든 금액 변동을 불변 이력으로 기록
CREDIT (판매자 수입):
- SALE: 판매 완료 (배송 완료 후)
DEBIT (판매자 차감):
- COMMISSION: 플랫폼 수수료
- RETURN_CLAWBACK: 반품으로 인한 환수
- PENALTY: 위반 패널티
[수수료 구조]
카테고리별 차등:
전자기기: 8%
의류: 12%
식품: 5%
디지털: 20%
판매금액 구간별:
~100만원: 기본율
100~500만원: 기본율 - 1%
500만원↑: 기본율 - 2%
[정산 주기]
에스크로 홀드: 배송 완료 후 7일
(반품 기간 종료 대기)
정산 주기: 매주 화요일 (지난 주분)
지급 방법: 가상계좌 → 판매자 계좌 이체
2. 정산 구현
class SettlementService {
// 판매 완료 시 원장 기록
async recordSale(params: {
sellerId: string;
orderId: string;
orderItemId: string;
saleAmount: number;
category: string;
}) {
const commissionRate = await this.getCommissionRate(params.category, params.saleAmount);
const commission = Math.round(params.saleAmount * commissionRate);
const netAmount = params.saleAmount - commission;
await db.$transaction([
// 판매 수입 기록
db.sellerLedger.create({
data: {
sellerId: params.sellerId,
type: 'SALE',
amount: params.saleAmount,
orderId: params.orderId,
orderItemId: params.orderItemId,
settledAt: new Date(Date.now() + 7 * 86400_000), // 7일 홀드
},
}),
// 수수료 차감 기록
db.sellerLedger.create({
data: {
sellerId: params.sellerId,
type: 'COMMISSION',
amount: -commission,
orderId: params.orderId,
orderItemId: params.orderItemId,
commissionRate,
settledAt: new Date(Date.now() + 7 * 86400_000),
},
}),
]);
return { netAmount, commission, commissionRate };
}
// 반품 시 클로백
async processReturnClawback(orderItemId: string) {
const ledgers = await db.sellerLedger.findMany({
where: { orderItemId, type: { in: ['SALE', 'COMMISSION'] }, isPaid: false },
});
for (const ledger of ledgers) {
await db.sellerLedger.create({
data: {
sellerId: ledger.sellerId,
type: 'RETURN_CLAWBACK',
amount: -ledger.amount, // 반대 부호로 상쇄
orderItemId,
relatedLedgerId: ledger.id,
settledAt: new Date(),
},
});
}
}
// 주간 정산 배치
async runWeeklySettlement(sellerId: string) {
const cutoff = new Date();
const pendingLedgers = await db.sellerLedger.findMany({
where: {
sellerId,
isPaid: false,
settledAt: { lte: cutoff },
},
});
const totalAmount = pendingLedgers.reduce((s, l) => s + Number(l.amount), 0);
if (totalAmount <= 0) return; // 지급할 금액 없음
const settlement = await db.settlement.create({
data: {
sellerId,
amount: totalAmount,
ledgerIds: pendingLedgers.map(l => l.id),
status: 'PENDING',
scheduledAt: new Date(),
},
});
// 실제 이체 (은행 API 연동)
await bankTransfer({ sellerId, amount: totalAmount, settlementId: settlement.id });
await db.sellerLedger.updateMany({
where: { id: { in: pendingLedgers.map(l => l.id) } },
data: { isPaid: true, settlementId: settlement.id },
});
}
private async getCommissionRate(category: string, amount: number): Promise<number> {
const baseRates: Record<string, number> = {
electronics: 0.08, clothing: 0.12, food: 0.05, digital: 0.20,
};
let rate = baseRates[category] ?? 0.10;
if (amount > 5_000_000) rate -= 0.02;
else if (amount > 1_000_000) rate -= 0.01;
return rate;
}
}
async function bankTransfer(params: any) { /* 은행 API */ }
const db = {} as any;
마무리
정산 시스템의 핵심은 원장(Ledger) 방식이다. 판매, 수수료, 반품 클로백을 모두 불변 레코드로 기록하면 어떤 시점의 잔액이든 재계산할 수 있고, 감사 추적도 완벽하다. 에스크로 홀드 기간은 반품 기간과 연동해 미리 설정하고, 배치로 주기적 정산을 자동화한다.