Notes
Strategy2026

Evalia 결제·정산 시스템 구축

기업 결제 → 에스크로 잠금 → 등급별 보상 배분 → 원천징수·지급까지, 돈이 한 원도 새지 않게 만든 정산 파이프라인 구현 기록.

비즈니스 구조

기업 → 플랫폼(수수료 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(금융결제원)로 전환 (사업자 등록 후)
Next복지정책 자격 판정 엔진 — LLM을 쓰지 않기로 한 이유