🛠️ 개발자 매뉴얼

16. 일반결제·취소·정산 기능

이 문서는 구독 없이 발생하는 1회성(단건) 결제와 그 취소(환불), 그리고 매출·환불·순매출을 합산하는 정산/대시보드 집계가 코드에서 어떻게 흐르는지 추적한다. 호출 진입점 → 서비스 함수(file:line) → 토스 호출 → DB 갱신 → 감사 로그 → 서비스 알림 → 반환의 순서로 본다.

🍎

쉽게 말하면 단건결제는 "사전 등록한 카드(카드 보관함)에서 한 번 긁는" 결제이고, 취소는 그 청구를 토스로 되돌리는 것이며, 정산은 "얼마 벌고 얼마 돌려줬는지"를 합산하는 것이다.

함께 보기: 서비스 API, 카드 보관함, 서비스 알림, 21. 에러 테스트 — 단건결제 실패를 STG에서 재현하는 방법(QA)

16.1 기능 개요·관련 파일

무엇을 하는가

  1. 단건 결제 생성(결제창) — 외부 서비스가 POST /v1/payments/prepare로 주문을 선점(PENDING)하고, 사용자가 토스 결제창(카드 통합결제창)에서 인증한 뒤 POST /v1/payments/confirm으로 승인한다. 빌링키·카드 등록과 무관하다(2026-07-15 결제창 전환 — 카드 보관함은 구독 전용).
  2. 외부 사용자 취소POST /v1/payments/{order_id}/cancel. 전액·부분 취소를 모두 지원한다(cancel_amount=취소할 원금, 생략 시 잔여 전액 — 잔여가 남는 한 반복 가능). 수수료율은 취소 원금에 비례해 공제된다. 관리자가 취소를 실행한 이력이 있는 결제는 외부 취소가 차단된다.
  3. 어드민 취소 — 관리자 화면에서 POST /admin/payments/{payment_id}/cancel. 수수료 없이 전액/부분 취소가 가능하며, 부분취소는 누적된다.
  4. 정산·대시보드 집계 — 매출(DONE+CANCELED 원금), 환불(취소 상세 원장 payment_cancellations의 회차별 cancel_amount 합 — 취소 발생 시각 귀속), 순매출(매출−환불)을 서비스별/기간별로 합산한다.

관련 파일

역할 파일
단건 결제 생성·취소·어드민 취소 도메인 로직 app/services/payments.py
외부 API 진입점(생성/취소) app/api/v1/payments.py
어드민 취소 진입점 app/admin/routes/payments.py
취소 수수료 계산(공유 공식) app/services/billing_math.py
정산 집계 app/services/settlement.py
대시보드 매출·환불 집계 app/services/dashboard.py
응답 스키마 app/schemas/api.py(PaymentResponse)
이벤트 상수·알림 발송 app/notifications/service_notify.py

이 장에서 쓰는 용어 — 먼저 읽으면 아래가 쉬워진다

용어
부분취소 결제 금액의 일부만 환불하는 것. 남은 금액은 나중에 또 취소할 수 있다
누적 부분취소를 할 때마다 그 환불액이 이전 환불액 위에 계속 쌓인다(canceled_amount에 합산)는 뜻. "취소는 1번만"이 아니라 잔여가 0이 될 때까지 여러 번 반복 가능하고, 합계가 결제금액에 도달하는 순간 결제가 CANCELED로 종료된다
잔여 취소가능 원금(cancel_remaining) 아직 취소할 수 있는 금액 = 결제금액 − (지금까지 환불한 금액 + 지금까지 뗀 수수료)
취소 원장(payment_cancellations) 취소 1회 = 1행으로 쌓이는 이력 테이블 — "언제, 누가, 얼마를, 왜" 환불했는지의 원본 기록
귀속 집계(정산·대시보드)에서 그 금액을 어느 달(기간)의 숫자로 잡느냐 — 승인일 기준인지, 취소 실행일 기준인지

16.2 흐름 1 — 단건 결제 생성 (결제창 prepare/confirm)

결제창 전환(2026-07-15 스펙): 단건결제는 빌링키를 쓰지 않는다. 카드 보관함·카드 등록과 무관하게, 토스 결제창(카드 통합결제창) 인증 후 서버가 승인하는 2단계 API로 처리한다.

진입점 ① 준비 — POST /v1/payments/prepare (app/api/v1/payments.py prepare_payment)

external_user_id(이메일)/order_id/order_name/amount를 받아 prepare_one_off_payment(app/services/payments.py:43)가 처리한다:

  1. 입력 검증order_id 형식, 이메일 정규화, amount > 0. 상한은 GlobalSettings.one_off_max_amount(런타임 조정 가능)로 검사하고 초과 시 InputValidationError.
  2. 멱등성 검사 — 같은 (service_id, order_id)가 이미 있으면 새 주문 없이 기존 Payment 반환.
  3. PENDING 선커밋 — 토스 전달용 전역 고유 toss_order_id(t + uuid4 hex)를 만들어 Payment를 PENDING으로 저장하고 감사 로그(payment.one_off) 후 commit. 이 시점의 표식은 PENDING + toss_payment_key IS NULL = "결제창 승인 대기".
# app/services/payments.py — prepare_one_off_payment (선커밋 핵심)
toss_order_id = f"t{uuid.uuid4().hex}"
payment = Payment(..., order_id=order_id, toss_order_id=toss_order_id,
                  amount=amount, payment_type=PaymentType.ONE_OFF,
                  kind=PaymentKind.ONE_OFF, status=PaymentStatus.PENDING,
                  idempotency_key=toss_order_id, requested_at=now)

응답의 toss_order_id를 서비스 클라이언트가 결제창 SDK의 orderId로 사용한다(어드민 화면·토스 콘솔의 "토스 주문번호"와 동일 — 1:1 대조).

② 결제창(서비스 클라이언트) — 서버 코드 없음

tossPayments.payment({customerKey}).requestPayment({method:"CARD", orderId: toss_order_id, amount, orderName, successUrl, failUrl}) — Redirect 방식 고정(모바일 iframe 금지). 인증이 끝나면 successUrl 쿼리로 paymentKey·orderId·amount가 온다.

진입점 ③ 승인 — POST /v1/payments/confirm (app/api/v1/payments.py confirm_payment)

confirm_one_off_payment(app/services/payments.py)가 처리한다. 갱신 결제(_renew_one)와 동일하게 주문별 Redis 락 + FOR UPDATE 3단계로 동시 승인을 직렬화한다(보안 H-3):

  1. 주문별 락 획득lock:confirm:{service_id}:{toss_order_id}를 선점한다. 이미 다른 승인이 진행 중이면(successUrl 중복 리다이렉트·더블클릭·재시도) 토스를 재차 부르지 않고 409(결제 승인이 이미 진행 중입니다)로 즉시 거절 — 선행 호출이 확정하므로 클라이언트 재시도는 멱등 DONE으로 수렴한다.
  2. 주문 조회(FOR UPDATE)(service_id, toss_order_id, kind=ONE_OFF)로 PENDING 주문을 행 잠금과 함께 찾는다. 없으면 404. 이미 DONE이면 같은 payment_key일 때 기존 결제 반환 (멱등), 다른 키면 409.
  3. 금액 대조(선택) — 요청에 amount가 오면 서버 저장 금액과 비교, 다르면 422로 조기 차단.
  4. 승인 시도 표식 선커밋 — 토스 호출 전에 toss_payment_key를 기록·commit(행 잠금·커넥션을 토스 응답까지 쥐지 않게 반납). 타임아웃 시 정산 스윕이 "승인 시도 후 불명"(10분 규칙)으로 다루게 하는 내구성 장치.
  5. 토스 승인toss.confirm_payment(payment_key, toss_order_id, payment.amount)POST /v1/payments/confirm. 금액은 항상 서버 저장값 — 결제창·successUrl에서 위변조된 금액은 토스가 거절한다.
  6. FOR UPDATE 재취득 + 재검증 후 결과 분기 — 외부 호출 동안 다른 경로(웹훅·정산)가 상태를 바꿨을 수 있으므로 행을 재취득해 PENDING을 재확인한 뒤 확정한다(이미 확정됐으면 중복 적용 없이 반환). - TossTimeoutError(결과 불명): 절대 FAILED로 만들지 않고 PENDING 유지, 감사(payment.one_off_unresolved), HTTP 503 — 정산 스윕이 toss_order_id 재조회로 확정. - TossError ALREADY_PROCESSED_PAYMENT(직전 시도 타임아웃 후 재승인 등): toss_order_id재조회 후 실제 DONE이면 FAILED로 덮지 않고 성공으로 확정한다(이중결제·상태오염 방지). - 그 외 TossError(승인 거절): status=FAILED + failure_code/failure_message, 감사(payment.one_off_failed). - 성공: status=DONE + toss_payment_key·approved_at·raw_response(매출전표 링크 포함) 기록 후 commit.
  7. 서비스 알림(best-effort) — 성공 시 EVENT_PAYMENT_ONE_OFF 발송. 알림 실패는 본 처리에 영향 없음.

결제창 이탈 주문의 정리 — 정산 스윕(35분 유예)

결제창을 열고 결제를 완료하지 않으면 토스에 아무 기록이 없다. 정산 스윕(app/services/reconciliation.py)은 "결제창 승인 대기"(ONE_OFF + PENDING + toss_payment_key IS NULL) 주문을 35분(WINDOW_PENDING_GRACE, 토스 결제창 유효 30분 + 여유) 동안 보류한 뒤:

  • 토스에 DONE 존재(승인 유실) → DONE 복구
  • 진행 중 상태 → 다음 스윕으로 보류
  • 기록 없음 → FAILED(code=EXPIRED) ("결제창 유효시간 내 결제가 완료되지 않았습니다")

승인 시도 후 불명(toss_payment_key 기록됨)은 기존 10분 규칙으로 재조회·확정한다.

⚠️

중요: 카드 보관함은 이 흐름에 전혀 관여하지 않는다 — 카드 미등록·비활성 카드여도 단건결제는 정상 진행된다(카드/빌링키는 구독 전용).

16.3 흐름 2 — 외부 사용자 취소(수수료율 적용 · 전액/부분)

🍎

쉽게 말하면 — 사용자가 자기 서비스 화면에서 누르는 환불이다. 전액도, 일부만도 취소할 수 있고, 서비스가 수수료율을 정해뒀다면 취소하는 금액에서 수수료를 뗀 나머지가 돌아간다. 예시(수수료율 10%, 10,000원 결제): 4,000원을 부분취소하면 → 수수료 400원을 뗀 3,600원이 환불되고, 원금 4,000원이 소진되어 잔여 취소가능 원금은 6,000원이 된다. 남은 6,000원은 나중에 또 취소할 수 있고(누적), 잔여가 0이 되는 순간 상태가 CANCELED로 바뀐다. 단, 관리자가 이미 취소를 실행한 결제는 사용자 쪽에서 더 못 건드린다(고객센터 경로).

진입점

POST /v1/payments/{order_id}/cancelapp/api/v1/payments.py:168cancel_paymentcancel_one_off_payment 호출(app/api/v1/payments.py:188). 요청 본문의 cancel_amount(부분취소 원금, 생략=잔여 전액)를 그대로 전달하며, 외부 호출이므로 actor_user_id를 넘기지 않는다.

도메인 처리 단계 — app/services/payments.py:252

  1. 결제 조회(service_id, order_id) 스코프로 조회. 없으면 NotFoundError (app/services/payments.py:283~285).
  2. 상태 가드kind==ONE_OFF & status==DONE만 취소 가능(app/services/payments.py:296). 관리자(USER)가 취소를 실행한 이력이 있는 결제는 외부 취소 차단(취소 원장 actor_type 판정 — app/services/payments.py:307) — 사용자 자신의(SERVICE) 부분취소 후 추가 취소는 잔여 한도 안에서 허용된다.
  3. 정책 가드service.cancellation_enabled가 꺼져 있으면 PaymentFailedError("CANCEL_DISABLED") (app/services/payments.py:311).
  4. 잔여·금액 검증 — 잔여 취소가능 원금 = amount − (canceled_amount누적 + cancel_fee누적). 잔여 0이면 ConflictError(:318), 요청 cancel_amount(생략 시 잔여 전액)가 1~잔여 범위를 벗어나면 InputValidationError(422) (app/services/payments.py:323~324).
  5. 수수료 계산compute_cancel_fee(cancel_amount, ...)로 이번 취소 원금에 비례한 (fee, refund)를 구한다. 조회 응답·화면 표시와 동일한 공식을 공유한다.
# app/services/billing_math.py:117
fee = amount * fee_percent // 100   # 정수 내림
return fee, amount - fee            # (수수료, 환불액)
  1. 토스 취소 — 최초 취소이면서 전액(수수료 0)일 때만 cancel_amount=None(전액취소), 그 외는 환불액을 명시한 부분취소. 실패 시 상태·누적액 보존 + 감사(payment.cancel_failed) 후 재발생(멱등 재시도 가능).
  2. 확정canceled_amount += refund, cancel_fee += fee(회차 수수료 누적), canceled_at 갱신 + 취소 상세 원장(payment_cancellations) 1행 추가. 원금 소진이 전액에 도달하면 status=CANCELED, 남으면 DONE 유지(추가 부분취소 가능 — 어드민 부분취소와 동일 규칙). 감사(payment.canceled, actor_type="SERVICE", cancel_base·remaining·partial 포함).
  3. 알림EVENT_PAYMENT_ONE_OFF_CANCELED(전액/부분 구분, best-effort).
💡

참고: 외부 사용자 취소도 부분취소를 지원한다(cancel_amount — 취소할 원금 지정, 잔여가 남는 한 반복 가능). 수수료는 취소 원금에 비례해 공제된다. 응답의 cancel_remaining(= amount − 누적 환불 − 누적 수수료)이 부분취소 상한이며, 어드민 취소와의 차이는 수수료 적용·취소 게이트 검사 여부다.

16.4 흐름 3 — 어드민 취소(수수료 없이, 잔여가 남는 한 여러 번)

🍎

쉽게 말하면 — 관리자 취소는 "환불 금액을 직접 정해서, 몇 번에 나눠서라도 돌려주는" 기능이다. 수수료를 떼지 않고 지정한 금액이 그대로 환불된다. 여기서 누적이란: 취소를 할 때마다 그 환불액이 이전 환불액 위에 계속 쌓인다(canceled_amount에 합산)는 뜻이다. 부분취소는 1회용이 아니다 — 잔여가 남아 있는 한 반복할 수 있고, 쌓인 합계가 결제금액에 도달하는 순간 결제가 CANCELED로 종료된다. 그 전까지 상태는 DONE으로 유지된다("취소 배지가 없으니 취소 안 됨"으로 오해하기 쉬운 지점).

10,000원 결제를 두 번에 나눠 환불하는 예:

시점 이번 환불 canceled_amount(누적) 잔여 취소가능 상태
결제 직후 0원 10,000원 DONE
1차 부분취소 3,000원 3,000원 3,000원 7,000원 DONE + 부분취소 배지
2차 부분취소 7,000원 7,000원 10,000원 0원 CANCELED (종료)

회차별 내역("언제 얼마씩")은 취소 원장에 1행씩 남아 결제 목록의 하위 행과 결제 상세의 회차별 표에서 확인한다.

진입점

관리자 화면 POST /admin/payments/{payment_id}/cancelapp/admin/routes/payments.py:131payment_cancel. CSRF·스코프 검증 후, 폼 cancel_amount(빈값=전액, 숫자=부분)를 파싱해 호출한다.

# app/admin/routes/payments.py:169
await payment_service.admin_cancel_one_off_payment(
    db, toss, redis, payment=payment, cancel_amount=cancel_amount,
    reason="관리자 취소", actor_user_id=ctx.user.id, notifier=notifier)

외부 사용자 취소와의 차이

항목 외부 사용자 취소 어드민 취소
취소 수수료 취소 원금에 비례 적용(cancellation_fee_percent) 없음(지정 금액 그대로 환불)
취소 허용 게이트 cancellation_enabled 검사 무시(항상 가능)
부분 금액 지정 가능(cancel_amount — 취소할 원금) 가능(cancel_amount — 환불액)
부분취소 누적 누적(잔여가 남는 한 반복) 누적(여러 번 가능)
관리자 개입 결제 취소 이력에 관리자(USER) 회차가 있으면 차단(409) 항상 가능
행위자 감사 SERVICE USER(관리자 UUID)

도메인 처리 단계 — app/services/payments.py:403

  1. 상태 가드kind==ONE_OFF & status==DONE. 부분취소 후에도 DONE을 유지하므로 DONE이면 잔여가 있다고 본다 (app/services/payments.py:446).
  2. 잔여 계산remaining = amount − 기존 누적 환불액(canceled_amount). 잔여 0이면 ConflictError("이미 전액 취소된 결제입니다") (app/services/payments.py:451).
  3. 금액 검증cancel_amount=None이면 잔여 전액, 지정 시 1 ~ 잔여 범위. 벗어나면 InputValidationError (app/services/payments.py:456~457).
  4. 토스 취소 — 최초 전액취소(already==0 & refund==amount)만 cancel_amount 생략, 그 외는 환불액 명시 (app/services/payments.py:461~464). 실패 시 상태·누적액 보존 + 감사(payment.cancel_failed).
  5. 누적 확정canceled_amount += refund. 잔여 0이 되면 CANCELED로 전환, 남으면 DONE 유지(추가 취소 가능). cancel_fee는 무수수료라 건드리지 않는다. 부분취소 회차마다 취소 상세 원장(payment_cancellations)에 1행씩 쌓여 "언제 얼마씩" 환불했는지 복원할 수 있다 (app/services/payments.py:477~481).
# app/services/payments.py:477
new_total = already + refund
payment.canceled_amount = new_total
payment.canceled_at = utcnow()
if new_total >= payment.amount:
    payment.status = PaymentStatus.CANCELED       # 전액 도달 → 취소 종료
  1. 알림EVENT_PAYMENT_ONE_OFF_ADMIN_CANCELED. desc에 "전액취소/부분취소"와 이번 환불액·누적액을 담는다 (app/services/payments.py:501).

16.5 응답 스키마(PaymentResponse)

app/schemas/api.py:283PaymentResponse는 결제 결과와 함께 취소 안내 필드를 반환한다(서비스가 "지금 취소하면 얼마 빠지고 얼마 환불"을 미리 보여줄 수 있게).

필드 의미
status PENDING / DONE / FAILED / CANCELED
kind / payment_type SUBSCRIPTION/ONE_OFF / FIRST/RENEWAL/RETRY/ONE_OFF
receipt_url 토스 매출전표(영수증) 링크. 카드결제(DONE)만 보통 존재, 그 외 null
cancelable 단건·DONE·서비스 취소허용·cancel_remaining>0·관리자 취소 이력 없음일 때 true — 부분취소 후 잔여가 있으면 계속 true
cancel_blocked_reason 잔여가 남았는데 취소 불가한 사유 — ADMIN_HANDLED(관리자 개입 → 고객센터) / CANCEL_DISABLED(서비스 정책) / null
cancel_remaining 취소 가능 원금 = amount − (누적 환불 + 누적 수수료). 부분취소 요청(cancel_amount)의 상한
cancel_fee_percent / cancel_fee / cancel_refund_amount 취소 이력이 없으면 "지금 전액 취소 시" 예상액, 취소 이력이 있으면 실제 누적값
canceled_amount 실제 누적 환불액(부분취소 시 DONE이어도 >0)
net_amount 순매출(amount − canceled_amount)
cancellations 회차별 취소 이력(옵션 — include_cancellations=true 조회 시, 13.6.4 참고)
💡

참고: toss_payment_key·raw_response 같은 내부 필드는 노출하지 않는다. 단, 매출전표 링크(receipt_url)만은 raw_response.receipt.url에서 안전 추출해 노출한다(app/models/payment.pyPayment.receipt_url). 어드민 결제목록과 서비스단(샘플 /history)이 같은 링크를 사용해 영수증을 새 탭으로 연다.

참고(전표 주문번호 접두어): 매출전표 화면의 주문번호에는 토스가 상점 구분용 접두어(예: 19e341_)를 붙여 표시한다. 우리가 보낸 toss_order_id와 토스 API가 되돌려주는 orderId(raw_response)에는 접두어가 없다 — 전표와 대조할 때는 _ 뒤 부분을 본다.

16.6 흐름 4 — 매출·환불·순매출 집계

매출/환불의 핵심은 취소 수수료는 매출로 보유하고 환불액만 빼는 것이다. 정산(settlement.py)과 대시보드(dashboard.py)가 동일 결과가 되도록 맞춰져 있다.

취소 상세 원장(payment_cancellations)

취소(환불)는 회차마다 payment_cancellations(app/models/payment_cancellation.py)에 1행씩 append 된다 — 환불액(cancel_amount)·수수료(cancel_fee)·사유·행위자(actor_type: USER/SERVICE/SYSTEM)·토스 transactionKey·취소 시각(canceled_at). 기록 지점은 5곳: 외부 사용자 취소, 어드민 취소(payments.py), 요금제 변경 자동원복·업그레이드 환불(subscriptions.py), 웹훅 외부취소 동기화(webhooks.py). payments.canceled_amount(누적)·cancel_fee·canceled_at은 잔여 환불가능액 계산과 대시보드 집계용 비정규화 캐시로 유지되고, 건별 이력의 원천은 이 원장이다. 도입 시점 이전의 취소는 마이그레이션(b7c8d9e0f1a2)이 누적액 합산 1건(reason=backfill, actor=SYSTEM)으로 백필했다.

정산(settlement.py)

app/services/settlement.py:54settlement_summary는 기간 내 DONE+CANCELED(승인일 approved_at 기준)를 서비스별로 합산한다.

  • 총매출 = sum(amount) (DONE+CANCELED 원금, approved_at 귀속)
  • 환불 = 취소 상세 원장의 sum(cancel_amount)취소 발생 시각(canceled_at) 귀속. 5월 결제를 6월에 환불하면 환불은 6월 정산에 잡히고, 마감한 5월 정산은 소급 변경되지 않는다. 매출 없이 환불만 있는 달의 서비스도 행으로 나타난다(총매출 0, 순매출 음수).
  • 순매출 = net_amount 프로퍼티 = amount − refund_amount (app/services/settlement.py) — 승인·환불의 귀속 월이 다를 수 있으므로 "이번 기간 승인 − 이번 기간 환불"을 뜻한다.

대시보드(dashboard.py)

대시보드는 결제 1건의 순매출 기여액_revenue_expr()로 계산한다 — 어드민 부분취소(DONE 유지)에서도 환불액을 빼야 정확하다.

# app/services/dashboard.py:137
return case(
    (Payment.status == PaymentStatus.DONE,
     Payment.amount - func.coalesce(Payment.canceled_amount, 0)),
    (Payment.status == PaymentStatus.CANCELED,
     Payment.amount - func.coalesce(Payment.canceled_amount, Payment.amount)),
    else_=0)

환불금액 카드의 환불 합계는 _refund_between()(app/services/dashboard.py:159)이 취소 상세 원장(payment_cancellations)에서 취소 발생 시각(canceled_at) 기준으로 집계한다 — 부분취소 회차도 원장에 1행씩 있으므로 자연히 합산되고, 지난달 결제를 이번 달에 환불하면 환불은 이번 달에 잡힌다(정산과 동일 철학). 매출 인식 시점은 원결제 승인일(approved_at)이다.

이번 달 요약 카드 4종(총매출·구독매출·일반매출·환불금액)은 _revenue_cards()(app/services/dashboard.py:184)가 만든다. 환불이 0원이면 긍정 색(up=True)으로 표시한다.

💡

참고(환불 귀속 기준 정리): 환불금액 카드(_refund_between)와 정산 화면은 취소 원장(canceled_at) 기준 — 환불이 실행된 달에 잡힌다. 반면 매출/순매출 계산(_revenue_expr)과 서비스별 매출 요약 표payments.canceled_amount(누적 캐시) 기준의 결제 귀속 스냅샷이다. 취소가 승인 월과 다른 월에 일어나면 요약 표와 정산 화면의 월별 수치는 의도적으로 다를 수 있다 — 마감 숫자는 정산 화면이 기준.

16.7 제약·에러 처리

상황 동작 위치
주문 준비 검증(order_id 형식·금액 범위) InputValidationError payments.py:64, 68, 73
같은 (service, order_id) 재시도 재결제 없이 기존 Payment 반환(멱등) payments.py:76~79
승인 중복(동시 confirm) 주문별 락 미획득 → ConflictError 409 payments.py:147, 151
승인 대상 아님(이미 DONE·비PENDING) 같은 키면 멱등 반환, 아니면 ConflictError payments.py:163, 165
토스 타임아웃(결과 불명) PENDING 유지, 503 — 절대 FAILED 금지 payments.py:186
ALREADY_PROCESSED_PAYMENT 재조회 후 실제 DONE이면 성공 확정(FAILED 금지) payments.py:198
토스 실패 확정 FOR UPDATE 재검증 후 FAILED + 실패코드 payments.py:223
외부 취소인데 정책 꺼짐 PaymentFailedError(CANCEL_DISABLED) 402 payments.py:311
외부 취소인데 관리자가 취소 개입한 결제 ConflictError 409(고객센터 안내 — 이중환불·정산 혼선 방지) payments.py:307
외부 취소 잔여 0(이미 전액 취소) ConflictError 409 payments.py:318
외부 취소 금액이 취소가능금액 초과·1원 미만 InputValidationError 422 payments.py:323~324
어드민 취소 잔여 0 ConflictError(전액 취소 완료) payments.py:451
어드민 취소 금액 범위 초과 InputValidationError(1~잔여) payments.py:456~457
취소 토스 실패 상태·누적액 보존, 감사 후 재발생(멱등 재시도) payments.py:357(외부), 474(어드민)
⚠️

중요: 어드민 부분취소가 끝나도 잔여가 남아 있으면 statusDONE이다. "CANCELED가 아니니 취소 안 됨"으로 오해하지 말고 canceled_amount/잔여로 판단해야 한다.

16.8 유지보수 팁

  1. 수수료 공식은 한 곳뿐 — 화면 표시, API 응답, 실제 취소가 모두 compute_cancel_fee(app/services/billing_math.py:110)를 쓴다. 공식을 바꾸면 세 곳이 동시에 바뀐다. 직접 amount × percent를 다시 쓰지 말 것.
  2. 환불 귀속 기준을 흔들지 말 것 — 환불의 "언제" 귀속은 두 갈래가 의도된 설계다: 환불금액 카드(_refund_between)·정산 화면은 취소 원장(canceled_at) 기준, 순매출 식(_revenue_expr)·서비스별 요약 표는 canceled_amount 스냅샷 기준(16.6 참고 노트). 이 구분을 모르고 한쪽을 "일치"시키면 문서·화면 안내와 어긋난다. 계산식 자체(순매출 = 원금 − 환불, 수수료는 매출로 보유)는 어디서든 동일해야 한다.
  3. 상한 조정 — 단건 상한은 런타임 GlobalSettings.one_off_max_amount로 즉시 조일 수 있다(payments.py:70~73). 기본값보다 높이려면 schemas/api.pyle= 제약도 함께 올려야 한다(Pydantic 경계 검증이 먼저 걸린다).
  4. 타임아웃을 FAILED로 바꾸지 말 것 — 이중 결제 위험. 결과 불명은 항상 PENDING 유지가 원칙이다.
  5. 알림은 best-effortnotifier.send(...) 실패가 결제/취소를 깨면 안 된다. 알림 누락이 의심되면 17. 서비스 알림을 본다.