정산 백엔드 설계 (안)
정산 작업을 맡아 주시는 데 도움이 되었으면 해서 개발할 내용을 정리했던 것을 공유 드립니다. 전월 거래를 매월 8일 0시에 스냅샷으로 확정하고, 담당자가 CSV를 받아 은행앱에서 수동 송금한 뒤 완료를 기록하는 월간 사이클입니다.
전제 — Rails 7.1.6 · Ruby 3.4.3 ·PayoutProcessorType::MANUAL(CSV 수동 지급) 기반입니다.User#unpaid_balances_up_to_date,Balance#mark_processing!은 이미 있는 메서드를 그대로 쓰려고 합니다.
01전체 흐름
셀러의 계좌 등록부터 다음 달 사이클 복귀까지. 대상자·비대상자 두 갈래를 같이 그렸습니다.
암호문 저장
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 1건. 비대상자는 Payment 미생성, 잔액은 다음 달로 이월.
- 계좌 원문은 User에 암호화 저장. 스냅샷에는 뒷 4자리만.
- 계좌 잠금은 별도 칼럼 없이 "in_progress Payment 존재 여부"로 판단.
- 평문은 CSV 다운로드 시점에만 노출.
Payment를 새 모델로 분리하지 않고 기존 모델에 칼럼만 추가합니다. 기존Payment ↔ Balance연관·PayoutProcessorType::MANUAL구분자 그대로 사용하면 추가 코드가 가장 적습니다.
기존 메서드는 건드리지 않고, 도프켓 정산용 메서드는 모두_doppket접미사로 신설합니다(예:Payments::Payouts.create_monthly_settlement_snapshot_doppket). gumroad 경로와 도프켓 경로가 같은 모델을 공유할 뿐, 호출 경로는 분리합니다.
02타임라인
같은 흐름을 시간 축으로 본 그림입니다.
비대상자의 잔액은 그대로 남아 다음 달 8일에 재판정됩니다.
03새로 필요한 것들
DB
- User:
bank_account_number(encrypted),bank_account_number_last_four - Payment:
settlement_state,settlement_snapshot(jsonb),settlement_month(date,(user_id, settlement_month)유니크)
코드
- User:
encrypts :bank_account_number, last_four 캐시 콜백,can_update_payout_info?(in_progress Payment 존재 여부로 파생) - Payment:
SETTLEMENT_*상수,update_settlement_state! - 스냅샷 서비스
create_monthly_settlement_snapshot_doppket - Sidekiq:
CreateMonthlySettlementSnapshotsJob(디스패처) + 워커 - Settings::PaymentsController
before_action가드 Admin::Payouts::MonthlySettlementsController(목록·상세·CSV·완료)
UI
- NewBalancePage: 정산 상태 배지 + 잠금 안내 + 비대상 안내
- 어드민 월 목록 / 단건 상세
04데이터 모델
계좌 정보는 User에, 월별 정산 기록은 Payment에 둡니다.
User — 계좌 정보
은행 표시용 정보는 korea_bank_account_info json 칼럼 하나로 묶어 둡니다. 전체 계좌번호 원문은 DOPP-428에서 Rails 7.1 Active Record Encryption을 쓰는 별도 칼럼(bank_account_number)으로 추가됩니다.
| 칼럼 | 타입 | 역할 |
|---|---|---|
korea_bank_account_info NEW | json | bank_code·bank_name·account_number_last_four·account_holder_full_name 묶음 |
bank_account_number DOPP-428 | string (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
endDOPP-428 머지 후에는 encrypts :bank_account_number 선언, bin/rails db:encryption:init으로 키 생성, credentials 또는 ENV 주입.
Payment — 월별 정산 기록
기존 Payment에 칼럼 3개를 추가합니다.
| 칼럼 | 타입 | 역할 |
|---|---|---|
settlement_state NEW | string | pending / in_progress / settled |
settlement_snapshot NEW | jsonb | 8일 0시 시점의 요약 (아래 구조) |
settlement_month NEW | date | 정산 대상 월의 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)
end068일 0시 스냅샷 잡
매월 8일 0시(KST) Sidekiq Cron. 재실행 안전하게 멱등성 포함.
- 디스패처 + 워커 2단. 디스패처가 유저별로
perform_later, 실패한 유저만 재큐잉. (user_id, settlement_month)유니크 인덱스로 중복 생성 방지.- 잔액 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: "정산 진행 중이므로 계좌 변경이 불가합니다."
end0710일 CSV 다운로드와 수동 송금
입금은 자동화하지 않습니다. 담당자가 어드민에서 CSV를 내려받고 은행앱에서 직접 송금한 뒤 어드민에서 완료를 기록합니다. PayoutProcessorType::MANUAL이 이미 "CSV로 수동 지급"을 의미합니다.
담당자 동선은 세 단계입니다.
- CSV 다운로드.
state = in_progress로 바뀌고 셀러 계좌가 잠깁니다. CSV 열은user.bank_account_number(복호화됨)·예금주·금액·settlement_month입니다. - 은행앱에서 송금. 시스템 바깥 동작이라 자동화하지 않습니다.
- 어드민에서 "입금 완료" 기록.
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으로 이미 데이터를 받고 있어서, 상태 표현 세 가지만 추가합니다.
- 정산 상태 배지 —
settlement_state에 따라 "대기 / 진행 중 / 완료" - 잠금 안내 —
payout_info_locked일 때 안내 문구 + 변경 버튼 비활성화 - 비대상 배지 —
settlement_state = skipped일 때 "이번 달 정산 대상이 아닙니다 (최소 금액 미달)". 잠금이 없어 계좌 변경은 평소처럼 허용
어드민 (신규 컨트롤러)
기존 Admin::Users::PayoutsController는 유저 한 명의 지급 이력 단위라서, 월별 관점의 화면은 새 컨트롤러로 분리합니다. 경로에 settlement_month를 YYYY-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 |
| 유저 | 예금주 | 금액 | 상태 | 마지막 변경 | |
|---|---|---|---|---|---|
| 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 | 상세 |
목록 화면에서 Cron 완료 여부는 "대상 유저 수 = 해당 월 Payment 수"로 확인합니다. 스킵도 Payment로 남기 때문에 count 쿼리 한 번으로 충분합니다.
09머지 순서
암호화 칼럼이 전제 조건이라 DOPP-428이 먼저 들어가야 합니다. 기존에 쌓인 계좌 데이터가 없어서 백필 단계는 따로 필요하지 않습니다.
- DOPP-428 머지 — User에
bank_account_number,bank_account_number_last_four추가 +encrypts :bank_account_number선언. 이후 저장부터 자동 암호화·캐시됩니다. - 스테이징 키 점검 — 키 생성과 복호화 동작 확인.
- DOPP-564 머지 — User에
payout_info_locked, Payment에settlement_state·settlement_snapshot·settlement_month추가. 같은 마이그레이션에(user_id, settlement_month)유니크 인덱스. - 첫 월 드라이런 — Sidekiq Cron 등록 후 소수 유저로 스냅샷 생성·잠금·CSV·완료 기록까지 한 사이클 돌려봅니다.
관련 이슈
- DOPP-428
- User에 Active Record Encryption 적용. 이 설계의 전제
- DOPP-530
- 정산 기간 표시. 월 단위 표시는 구현 완료
- DOPP-539
- 매월 8일 마감/스냅샷. 이 설계의 구현체
- DOPP-562
- 정산 설정 UI. 428 이후 합류
- DOPP-570
- 계좌 암호화 등록 흐름. 428 하위