DOPP-564

정산 백엔드 설계 (안)

정산 작업을 맡아 주시는 데 도움이 되었으면 해서 개발할 내용을 정리했던 것을 공유 드립니다. 전월 거래를 매월 8일 0시에 스냅샷으로 확정하고, 담당자가 CSV를 받아 은행앱에서 수동 송금한 뒤 완료를 기록하는 월간 사이클입니다.

전제 — Rails 7.1.6 · Ruby 3.4.3 · PayoutProcessorType::MANUAL(CSV 수동 지급) 기반입니다. User#unpaid_balances_up_to_date, Balance#mark_processing!은 이미 있는 메서드를 그대로 쓰려고 합니다.

01전체 흐름

셀러의 계좌 등록부터 다음 달 사이클 복귀까지. 대상자·비대상자 두 갈래를 같이 그렸습니다.

flowchart TD A([셀러가 계좌 등록/변경]) -->|encrypts| B[(User
암호문 저장
last_four 캐시)] B --> C{{매월 8일 0시
Cron 발화}} C -->|유저 단위 perform_later| D{잔액 ≥ threshold?} D -- 아니오 --> S[Payment 만들지 않음
잔액은 다음 달 이월] D -- 예 --> F[Payment 생성
state = pending
Balance processing] F --> G[담당자가 CSV 다운로드
state = in_progress
→ 셀러 계좌 변경 차단] G --> I[담당자가 은행앱에서
수동 송금] I --> H[어드민에서 완료 기록
state = settled] classDef skip fill:#fef3c7,stroke:#d97706,color:#78350f classDef settled fill:#dcfce7,stroke:#15803d,color:#14532d classDef crypto fill:#e0e7ff,stroke:#4338ca,color:#312e81 class S skip class H settled class B,G crypto
Payment를 새 모델로 분리하지 않고 기존 모델에 칼럼만 추가합니다. 기존 Payment ↔ Balance 연관·PayoutProcessorType::MANUAL 구분자 그대로 사용하면 추가 코드가 가장 적습니다.
기존 메서드는 건드리지 않고, 도프켓 정산용 메서드는 모두 _doppket 접미사로 신설합니다(예: Payments::Payouts.create_monthly_settlement_snapshot_doppket). gumroad 경로와 도프켓 경로가 같은 모델을 공유할 뿐, 호출 경로는 분리합니다.

02타임라인

같은 흐름을 시간 축으로 본 그림입니다.

gantt title N월 정산 사이클 (전월 N-1월 거래 → N월 10일 입금) dateFormat YYYY-MM-DD axisFormat %d일 section 전월(N-1) 1일 ~ 말일 거래 발생 :active, range, 2026-03-01, 2026-03-31 section 당월(N) · 대상자 1일~7일 취소·환불 반영 :crit, adjust, 2026-04-01, 2026-04-07 8일 0시 스냅샷 생성 :milestone, snap, 2026-04-08, 0d 8일~10일 계좌 잠금 :lock, 2026-04-08, 2026-04-10 8일~10일 담당자 확인 :active, review, 2026-04-08, 2026-04-10 10일 입금 실행 :milestone, deposit, 2026-04-10, 0d section 당월(N) · 비대상자 1일~7일 취소·환불 반영 :crit, adjust2, 2026-04-01, 2026-04-07 8일 이후 Payment 미생성·잔액 이월 :done, carry, 2026-04-08, 2026-04-30

비대상자의 잔액은 그대로 남아 다음 달 8일에 재판정됩니다.

03새로 필요한 것들

DB

코드

UI


04데이터 모델

계좌 정보는 User에, 월별 정산 기록은 Payment에 둡니다.

User — 계좌 정보

은행 표시용 정보는 korea_bank_account_info json 칼럼 하나로 묶어 둡니다. 전체 계좌번호 원문은 DOPP-428에서 Rails 7.1 Active Record Encryption을 쓰는 별도 칼럼(bank_account_number)으로 추가됩니다.

칼럼타입역할
korea_bank_account_info NEWjsonbank_code·bank_name·account_number_last_four·account_holder_full_name 묶음
bank_account_number DOPP-428string (encrypted)전체 계좌번호. 입금 시점에만 복호화
korea_bank_account_info 예시
{
  "bank_code": "88",
  "bank_name": "신한은행",
  "account_number_last_four": "7890",
  "account_holder_full_name": "홍길동"
}

잠금 여부는 별도 칼럼 없이 in_progress Payment 존재 여부로 파생합니다.

app/models/user.rb
class User < ApplicationRecord
  # jiin: 전체 계좌번호는 DOPP-428에서 encrypts :bank_account_number로 추가 예정.
  # 이번 범위에서는 표시용 정보만 korea_bank_account_info json에 담는다.

  # jiin: 정산 진행 중인 Payment가 있으면 계좌 변경 불가.
  def can_update_payout_info?
    payments.where(settlement_state: Payment::SETTLEMENT_IN_PROGRESS).none?
  end
end

DOPP-428 머지 후에는 encrypts :bank_account_number 선언, bin/rails db:encryption:init으로 키 생성, credentials 또는 ENV 주입.

Payment — 월별 정산 기록

기존 Payment에 칼럼 3개를 추가합니다.

칼럼타입역할
settlement_state NEWstringpending / in_progress / settled
settlement_snapshot NEWjsonb8일 0시 시점의 요약 (아래 구조)
settlement_month NEWdate정산 대상 월의 1일. (user_id, settlement_month) 유니크

스냅샷은 뒷 4자리·집계값·balance_ids만. 전체 계좌번호는 담지 않습니다.

settlement_snapshot 예시
{
  "bank_code": "88",
  "bank_name": "신한은행",
  "account_number_last_four": "7890",
  "account_holder_full_name": "홍길동",
  "total_sales_cents": 150000,
  "total_fees_cents": 5250,
  "total_refunds_cents": 0,
  "net_payout_cents": 144750,
  "snapshot_at": "2026-04-08T00:00:00+09:00"
}

포함된 Balance는 payment.balances 연결로 조회합니다. 스냅샷에 별도로 id를 넣지 않습니다.

05상태와 잠금

상태는 상수로 선언합니다. 잠금은 별도 플래그 없이 in_progress Payment 존재로 파생합니다.

app/models/payment.rb
SETTLEMENT_PENDING     = "pending"
SETTLEMENT_IN_PROGRESS = "in_progress"
SETTLEMENT_SETTLED     = "settled"
SETTLEMENT_STATES      = [
  SETTLEMENT_PENDING, SETTLEMENT_IN_PROGRESS, SETTLEMENT_SETTLED
].freeze

def update_settlement_state!(new_state)
  raise ArgumentError unless SETTLEMENT_STATES.include?(new_state)
  update!(settlement_state: new_state)
end

068일 0시 스냅샷 잡

매월 8일 0시(KST) Sidekiq Cron. 재실행 안전하게 멱등성 포함.

  1. 디스패처 + 워커 2단. 디스패처가 유저별로 perform_later, 실패한 유저만 재큐잉.
  2. (user_id, settlement_month) 유니크 인덱스로 중복 생성 방지.
  3. 잔액 0 또는 순지급액 < payout_threshold_cents면 Payment를 만들지 않습니다. 잔액은 다음 달로 이월.
잡·워커·서비스 코드
# app/sidekiq/create_monthly_settlement_snapshots_job.rb
class CreateMonthlySettlementSnapshotsJob
  include Sidekiq::Job

  def perform(settlement_month_iso)
    settlement_month = Date.parse(settlement_month_iso)
    User.find_each do |user|
      CreateSettlementSnapshotWorker.perform_async(user.id, settlement_month_iso)
    end
  end
end

# app/sidekiq/create_settlement_snapshot_worker.rb
class CreateSettlementSnapshotWorker
  include Sidekiq::Job

  def perform(user_id, settlement_month_iso)
    Payments::Payouts.create_monthly_settlement_snapshot_doppket(
      User.find(user_id),
      settlement_month: Date.parse(settlement_month_iso)
    )
  end
end

# app/business/payments/payouts/payouts.rb
def self.create_monthly_settlement_snapshot_doppket(user, settlement_month:)
  # jiin: 멱등성 — 재실행해도 중복 생성 없음
  return if Payment.exists?(user: user, settlement_month: settlement_month)

  period_end = settlement_month.end_of_month
  balances   = user.unpaid_balances_up_to_date(period_end)
  net_cents  = balances.sum(&:holding_amount_cents)

  # jiin: 비대상이면 Payment를 만들지 않는다. 잔액은 다음 달로 이월.
  return if balances.empty? || net_cents < user.payout_threshold_cents

  payment = Payment.create!(
    user: user,
    processor: PayoutProcessorType::MANUAL,
    amount_cents: net_cents,
    currency: "krw",
    settlement_month: settlement_month,
    payout_period_end_date: period_end,
    settlement_state: Payment::SETTLEMENT_PENDING,
    settlement_snapshot: build_snapshot(user, balances, net_cents),
    state: "creating"
  )
  payment.balances = balances
  balances.each(&:mark_processing!)
  payment
end

전체 완료 여부는 "잔액 ≥ threshold 유저 수 = 해당 월 Payment 수"로 어드민에서 확인합니다.

스냅샷을 만드는 시점에는 아직 계좌를 잠그지 않습니다. 담당자가 CSV를 다운로드하여 in_progress로 전환될 때부터 can_update_payout_info?false가 되어, Settings 컨트롤러의 가드가 작동합니다.

Settings::PaymentsController 가드
before_action :ensure_payout_info_unlocked, only: [:update]

private

def ensure_payout_info_unlocked
  return if current_user.can_update_payout_info?
  redirect_to settings_payments_path,
              alert: "정산 진행 중이므로 계좌 변경이 불가합니다."
end

0710일 CSV 다운로드와 수동 송금

입금은 자동화하지 않습니다. 담당자가 어드민에서 CSV를 내려받고 은행앱에서 직접 송금한 뒤 어드민에서 완료를 기록합니다. PayoutProcessorType::MANUAL이 이미 "CSV로 수동 지급"을 의미합니다.

담당자 동선은 세 단계입니다.

  1. CSV 다운로드. state = in_progress로 바뀌고 셀러 계좌가 잠깁니다. CSV 열은 user.bank_account_number(복호화됨)·예금주·금액·settlement_month입니다.
  2. 은행앱에서 송금. 시스템 바깥 동작이라 자동화하지 않습니다.
  3. 어드민에서 "입금 완료" 기록. state = settled로 바뀌면서 같은 호출에서 User 잠금이 풀립니다.
CSV 생성 (컨트롤러 안)
payment.user.bank_account_number       # 복호화된 원문, CSV 행에만 사용
payment.settlement_snapshot["net_payout_cents"]
payment.settlement_month                # 송금 메모용

CSV에 담긴 원문 계좌는 다운로드 응답에만 노출됩니다. 로그·스냅샷·외부 서비스에는 남기지 않고, 다운로드는 담당자 권한이 있는 세션에서만 허용합니다.

08셀러·어드민 화면

셀러와 담당자가 흐름을 읽을 수 있도록 상태 표현을 더해줍니다.

셀러 (NewBalancePage)

payout_period_data_doppket으로 이미 데이터를 받고 있어서, 상태 표현 세 가지만 추가합니다.

스케치 · 셀러 화면 (대상자, in_progress)
2026년 4월 정산진행 중
정산 예정액₩ 144,750
정산 진행 중이므로 계좌 변경이 불가합니다. (10일 입금 완료 후 해제)
예금주
홍길동
변경
계좌
신한은행 ****7890
변경
스케치 · 셀러 화면 (비대상자, skipped)
2026년 4월 정산대상 아님
이번 달 정산 대상이 아닙니다 (최소 금액 미달). 잔액은 다음 달로 이월됩니다.
예금주
홍길동
변경
계좌
신한은행 ****7890
변경

어드민 (신규 컨트롤러)

기존 Admin::Users::PayoutsController는 유저 한 명의 지급 이력 단위라서, 월별 관점의 화면은 새 컨트롤러로 분리합니다. 경로에 settlement_monthYYYY-MM 꼴로 넣어서 URL에서 어느 달인지 바로 보이게 합니다.

액션설명엔드포인트
월 목록해당 월 전체 Payment 일람 (pending·in_progress·settled·skipped)GET /admin/payouts/settlements/:month (예 2026-04)
CSV 다운로드다운로드 시점에 in_progress로 전환 + 잠금GET /admin/payouts/settlements/:month/csv
단건 상세스냅샷 요약 + User에서 복호화해 가져온 전체 계좌GET /admin/payouts/settlements/:month/:id
완료 기록수동 송금 후 settled로 전환 + 잠금 해제PATCH /admin/payouts/settlements/:month/:id/settle
스케치 · 어드민 월 목록 (/admin/payouts/settlements/2026-04)
2026-04 정산 대상 120 · Payment 120 · 완료 ✓
CSV 다운로드 (전체 pending)
유저예금주금액상태마지막 변경
user#1023홍길동144,750대기04-08 00:00상세
user#1044김철수820,000진행 중04-08 09:12상세
user#1058이영희50,000완료04-10 11:40상세
user#1061박민수대상 아님04-08 00:00상세
대상 유저 수 = Payment 수면 초록 체크. 숫자가 맞지 않으면 빠진 유저만 재큐잉.
스케치 · 어드민 단건 상세
user#1023 · 홍길동진행 중
정산 월2026-04
순지급액₩ 144,750
포함 Balance[1023, 1044, 1058]
예금주홍길동
계좌 (복호화) 신한은행 110-234-567890
복사
이 화면에서만 원문 노출. 로그·외부 전달 없음.
입금 완료 기록 → settled

목록 화면에서 Cron 완료 여부는 "대상 유저 수 = 해당 월 Payment 수"로 확인합니다. 스킵도 Payment로 남기 때문에 count 쿼리 한 번으로 충분합니다.

09머지 순서

암호화 칼럼이 전제 조건이라 DOPP-428이 먼저 들어가야 합니다. 기존에 쌓인 계좌 데이터가 없어서 백필 단계는 따로 필요하지 않습니다.

  1. DOPP-428 머지 — User에 bank_account_number, bank_account_number_last_four 추가 + encrypts :bank_account_number 선언. 이후 저장부터 자동 암호화·캐시됩니다.
  2. 스테이징 키 점검 — 키 생성과 복호화 동작 확인.
  3. DOPP-564 머지 — User에 payout_info_locked, Payment에 settlement_state·settlement_snapshot·settlement_month 추가. 같은 마이그레이션에 (user_id, settlement_month) 유니크 인덱스.
  4. 첫 월 드라이런 — Sidekiq Cron 등록 후 소수 유저로 스냅샷 생성·잠금·CSV·완료 기록까지 한 사이클 돌려봅니다.

관련 이슈

DOPP-428
User에 Active Record Encryption 적용. 이 설계의 전제
DOPP-530
정산 기간 표시. 월 단위 표시는 구현 완료
DOPP-539
매월 8일 마감/스냅샷. 이 설계의 구현체
DOPP-562
정산 설정 UI. 428 이후 합류
DOPP-570
계좌 암호화 등록 흐름. 428 하위