비즈니스 구조
기업 → 플랫폼(수수료 15%) → 학생. 평가 1회 = 보상 1회, 등급 가중(S×1.3 / A×1.0 / B×0.8), 기타소득 원천징수 8.8%를 플랫폼이 대행한다.
돈을 다루는 코드에서 가장 무서운 건 실패가 아니라 '조용한 성공'이다. 중복 입금이 두 번 잠기고, 배분 합계가 1원 모자라고, 지급은 됐는데 기록이 안 남는 것 — 전부 예외 없이 성공처럼 보인다.
결제 파이프라인 (구현)
| 단계 | 수단 | 상태 |
|---|---|---|
| 견적서 발행 | jsPDF + Resend API | 운영 |
| 입금 감지 | 토스페이먼츠 가상계좌 웹훅 | 운영 |
| 세금계산서 | 팝빌 API | 운영 |
| 계좌 실명 확인 | 토스페이먼츠 계좌유효성 API | 운영 |
| 자동 지급 | 토스페이먼츠 지급대행 API (JWE 암호화·당일 지급) | 운영 |
| 정산 증명 | 스마트 컨트랙트 + Polygon 앵커링 | 설계 |
에스크로 상태 기계
Prisma PaymentPool 테이블로 자금을 잠근다 — PENDING → ACTIVE → DISTRIBUTING → DISTRIBUTED. 전이는 서버 액션에서만 가능하고, 각 전이는 이전 status를 WHERE 조건에 넣은 조건부 UPDATE라 같은 전이가 두 번 성공할 수 없다.
- PaymentPool — project_id · total_amount · platform_fee · reward_pool · status
- Distribution — user_id · grade · amount · tax · net_amount · status
- ChainLog — event_type · payload_hash · tx_hash · block_number
중복 입금을 막는 법
PG 웹훅은 '최소 1회' 전달이다. 즉 같은 입금 통지가 두 번 온다고 가정하고 짜야 한다. 웹훅 이벤트 키에 DB 유니크 제약을 걸고, 두 번째 삽입이 충돌하면 그대로 200을 돌려준다 — 재시도를 멈추게 하면서 상태는 건드리지 않는다.
// 웹훅 멱등 처리 — 두 번째 호출은 조용히 no-op
await prisma.$transaction(async (tx) => {
try {
await tx.webhookEvent.create({
data: { providerEventId, type: "DEPOSIT_CONFIRMED" },
});
} catch (e) {
if (isUniqueViolation(e)) return; // 이미 처리됨
throw e;
}
// status가 PENDING일 때만 성공하는 조건부 전이
const { count } = await tx.paymentPool.updateMany({
where: { id: poolId, status: "PENDING" },
data: { status: "ACTIVE", lockedAt: new Date() },
});
if (count === 0) return; // 경쟁 상태 — 다른 요청이 먼저 잠갔다
});1원도 남기지 않는 배분
등급 가중치로 나누면 반드시 나머지가 생긴다. 소수점을 반올림해 각자 지급하면 합계가 보상풀과 어긋나므로, 정수 원 단위로 몫을 계산한 뒤 남은 잔여 원을 가중치가 큰 순서로 1원씩 배분한다. 모든 금액은 처음부터 끝까지 정수(원)로만 다뤄 부동소수점을 아예 쓰지 않는다.
// 보상풀 전액을 정수 원으로 소진 (최대 잔여 방식)
const weights = evaluators.map((e) => GRADE_WEIGHT[e.grade]); // 1.3 / 1.0 / 0.8
const totalW = weights.reduce((a, b) => a + b, 0);
const base = weights.map((w) => Math.floor((rewardPool * w) / totalW));
let rest = rewardPool - base.reduce((a, b) => a + b, 0);
// 잘린 소수부가 큰 순서로 1원씩 — 합계는 항상 rewardPool과 일치
[...base.keys()]
.sort((a, b) => frac(b) - frac(a))
.slice(0, rest)
.forEach((i) => base[i]++);원천징수 8.8%
기타소득 원천징수는 소득세 8% + 지방소득세 0.8%로, 각각 따로 계산해 10원 미만을 절사한다. 한 번에 8.8%를 곱하면 국세청 신고 금액과 원 단위가 어긋난다. 지급명세서는 팝빌 API로 제출한다.
| 항목 | 계산 | 예시 (100,000원) |
|---|---|---|
| 소득세 | 지급액 × 8% (10원 절사) | 8,000원 |
| 지방소득세 | 소득세 × 10% (10원 절사) | 800원 |
| 실지급액 | 지급액 − 소득세 − 지방소득세 | 91,200원 |
커밋 전 불변식 검증
배분 트랜잭션을 커밋하기 직전에 회계 항등식을 다시 확인한다. 하나라도 어긋나면 전체를 롤백한다 — 부분 지급 상태가 남는 것보다 아무것도 안 된 편이 낫다.
- total_amount = platform_fee + reward_pool
- reward_pool = Σ(net_amount + tax)
- Distribution 건수 = 확정된 평가자 수
- 동일 (pool_id, user_id) 조합은 유니크 — 이중 지급 불가
남은 확장
- 에스크로 → 스마트 컨트랙트: 운영자도 임의 출금 불가, 기업이 코드로 직접 검증
- 기록 무결성 → 블록체인 앵커링(ethers.js + Polygon): 입금 잠금·등급 확정·지급 완료 시점에 해시 기록
- 지급 → 오픈뱅킹 API(금융결제원)로 전환 (사업자 등록 후)