Skip to content

fix(receipt): 월별 구매 조회가 환불·검증실패 영수증까지 세던 문제 - #480

Merged
ipdae merged 1 commit into
mainfrom
yang/hotfix-monthly-receipt-status
Aug 18, 2026
Merged

fix(receipt): 월별 구매 조회가 환불·검증실패 영수증까지 세던 문제#480
ipdae merged 1 commit into
mainfrom
yang/hotfix-monthly-receipt-status

Conversation

@ipdae

@ipdae ipdae commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

증상

시즌패스를 산 유저가 게임에서도 웹샵에서도 재구매가 막힙니다. 구매 가능 수량은 3/3(=3개 남음)으로 정상 표시되는데도 구매가 실패합니다.

원인

Receipt.get_user_receipts_by_monthstatus를 전혀 보지 않습니다.

filter_conditions = [
    cls.agent_addr == agent_addr,
    func.timezone('UTC', cls.created_at) >= utc_start,
    func.timezone('UTC', cls.created_at) < utc_end,
]   # ← 상태 조건 없음

그래서 검증 실패(INVALID)나 환불된 영수증도 "이번 달에 구매함"으로 집계됩니다. 이 함수는 시즌패스 보유 판정에 쓰이는 5개 엔드포인트가 공유합니다.

/api/admin/user-receipts/courage-pass
/api/admin/user-receipts/courage-pass-count
/api/admin/user-receipts/adventure-boss-pass
/api/admin/user-receipts/non-pass-amount
/api/admin/user-receipts/non-pass-count

반면 구매 제한 계산(get_purchase_count)은 이미 INIT/VALIDATION_REQUEST/VALID만 셉니다. 두 함수의 기준이 달라서 "제한엔 안 걸리는데 보유 판정엔 걸리는" 상태가 생깁니다.

실제 사고 경로

8/07  COURAGEPASS33Premium 구매 (41,000원)
8/10  구글이 미확인 결제로 자동 환불          ← 이 시점까지 영수증 없음
8/14  #477 배포 (/api/purchase/retry 500 수정)
8/17  막혀 있던 클라이언트 재시도가 서버에 도달
      → 영수증 생성(created_at=8/17) → 구글 검증 → 이미 환불됨 → status=INVALID
8/18  게임·웹샵 모두 재구매 차단

집계 기준이 purchased_at이 아니라 **created_at**이라, 8/07 구매건이 8/17에 생성되며 8월 건으로 잡혔습니다.

#477이 만든 버그는 아니지만, 그동안 도달하지 못하던 재시도를 풀어주면서 이 선재 결함이 드러났습니다.

수정

집계 대상을 get_purchase_count와 동일하게 맞춥니다.

cls.status.in_((
    ReceiptStatus.INIT,
    ReceiptStatus.VALIDATION_REQUEST,
    ReceiptStatus.VALID,
))

검증 (운영 DB, 읽기 전용)

문제 계정의 8월 집계입니다.

수정 전:  GPA.3366-6298-3175-96487  INVALID  couragepass33premium  created_at=8/17   → 1건 → 차단
수정 후:  (없음)                                                                      → 0건 → 구매 가능

구매 제한(get_purchase_count) 쪽은 애초에 0건이었습니다.

리뷰 반영

코드 리뷰에서 나온 지적을 반영했다.

  • non-pass-* 지출 집계 영향 측정 — 리뷰는 제외 대상이 6,816건(PURCHASE_LIMIT_EXCEED 등)이라 지출이 과소집계될 수 있다고 봤으나, 그 대부분은 무료·마일리지 영수증이고 non-pass-*only_paid_products=True(가격>0)라 애초에 세지 않는다. 운영 DB 실측 결과 유료 상품 기준 12개월간 새로 제외되는 건은 6행(4명), 그중 non-pass는 5행이다. 범위 분리는 하지 않는다.
  • 주석·커밋 메시지 정정REFUNDED_BY_*는 아무 데서도 기록되지 않으므로 이 PR이 실제로 거르는 건 "검증 실패"뿐이다. "환불까지 걸러진다"는 표현을 뺐다.
  • get_purchase_count와의 관계 정확화 — 상태 집합만 일치시켰고 시간 기준(purchased_at KST 일/주 vs created_at UTC 월)은 여전히 다르다는 점을 주석에 명시했다.
  • 테스트 교체inspect.getsource 문자열 검사를 버리고, 세션을 가로채 filter()에 들어간 SQLAlchemy 표현식을 컴파일해 검증한다. 조건식을 만들어두고 filter에 안 넣는 회귀를 실제로 잡는 것까지 확인했다(해당 줄 제거 시 2개 실패).

범위 밖 (후속 과제)

  • created_at 버킷팅 — 월말 결제가 다음 달에 영수증화되면 VALID인 정상 결제도 같은 차단이 난다. #477로 밀려 있던 재시도가 도달하기 시작해 노출도가 오른 상태다.
  • 지급 후 환불REFUNDED_BY_ADMIN/REFUNDED_BY_BUYER를 기록하는 경로가 없어 그런 영수증은 VALID로 남고 이 필터로 안 걸러진다.
  • 인게임 경로get_purchase_history를 쓰며 이미 같은 상태 필터가 있다. 이 PR은 웹샵 차단만 푼다.

영향 범위

  • 5개 엔드포인트 모두 "실제로 성립한 구매"를 묻는 용도라 이 필터가 정확한 의미입니다
  • INIT/VALIDATION_REQUEST(검증 진행 중)를 포함해 중복 구매를 막는 보수적 판정은 유지합니다
  • 회귀 테스트 3개 추가 (tests/shared/test_receipt_month_status.py)

배포

스키마 변경 없음. 다만 main 에 바우처(#476)가 머지돼 있고 그 마이그레이션이 운영에 미적용이라, 배포 시 alembic upgrade head를 함께 태우는 것을 권한다(테이블 2개 CREATE + merge revision).

운영 확인: WORKER_VOUCHER_GRANT_ENABLED 미설정, 시크릿에 voucher-grant-enabled 없음 → 바우처 워커는 기본값(False)로 early return 하므로 미적용 상태에서도 에러가 나지 않는다.

🤖 Generated with Claude Code

`get_user_receipts_by_month`가 status를 전혀 보지 않아, 스토어 검증에 실패한
영수증(INVALID)도 "이번 달에 구매함"으로 집계됐다. 시즌패스 보유 판정
(`/api/admin/user-receipts/*` 5개 엔드포인트)이 이 함수를 쓰므로, 그런 영수증
하나가 그 달의 재구매를 통째로 막는다.

실제 사고: 8/07 결제가 미확인 결제로 자동 환불됐는데, 8/14 배포(#477)로 막혀
있던 클라이언트 재시도가 풀리면서 8/17 에 그 결제의 영수증이 뒤늦게 생성됐고
구글 재검증에서 이미 환불된 건이라 INVALID 가 됐다. 집계 기준이 purchased_at 이
아니라 created_at 이라 8월 건으로 잡혔고, 웹샵에서 시즌패스를 다시 살 수 없었다.

운영 DB 확인: 해당 계정의 8월 집계가 수정 전 1건(INVALID) → 수정 후 0건.
구매 제한(get_purchase_count)은 애초에 INVALID 를 제외해 0건이었다. 즉 두 함수의
기준이 달라 "제한엔 안 걸리는데 보유 판정엔 걸리는" 상태였다.

집계 대상을 INIT/VALIDATION_REQUEST/VALID 로 좁힌다(get_purchase_count 와 동일한
상태 집합). 유료 상품 기준 12개월간 새로 제외되는 건은 6행(4명)뿐이라
지출 집계(non-pass-*)에 미치는 영향은 무시할 수준임을 운영 DB 로 확인했다.

범위 밖(후속 과제):
- created_at 버킷팅. 월말 결제가 다음 달에 영수증화되면 VALID 인 정상 결제도
  같은 차단이 난다. #477 로 밀린 재시도가 도달하기 시작해 노출도가 오른 상태다.
- 지급 후 환불된 건은 여전히 VALID 다. REFUNDED_BY_ADMIN/BUYER 를 기록하는
  경로가 없어 이 필터로 안 걸러진다.
- 인게임 경로는 get_purchase_history 를 쓰며 이미 같은 상태 필터가 있다.
  즉 이 PR 은 웹샵 차단만 푼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ipdae
ipdae force-pushed the yang/hotfix-monthly-receipt-status branch from 791e2f6 to 9e12ebc Compare August 18, 2026 12:49
@ipdae
ipdae merged commit 4cca284 into main Aug 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant