diff --git a/apps/shared/shared/enums.py b/apps/shared/shared/enums.py index 19b30b4..164a034 100644 --- a/apps/shared/shared/enums.py +++ b/apps/shared/shared/enums.py @@ -351,3 +351,13 @@ class PlanetID(bytes, Enum): HEIMDALL_INTERNAL = b"0x100000000001" IDUN_INTERNAL = b"0x100000000002" THOR_INTERNAL = b"0x100000000003" + + +class VoucherGrantStatus(IntEnum): + """(PLD-1468) NCG Voucher 아웃박스 상태 — 포탈 발급/회수 멱등·재시도 추적.""" + + PENDING = 0 # grant 대기/진행 + GRANTED = 1 # 포탈 grant 성공 + REVOKE_PENDING = 2 # 환불 감지, revoke 대기 + REVOKED = 3 # 포탈 revoke 성공 + FAILED = 4 # 재시도 소진(수동 개입) diff --git a/apps/shared/shared/models/__init__.py b/apps/shared/shared/models/__init__.py index a7d2d95..cb69989 100644 --- a/apps/shared/shared/models/__init__.py +++ b/apps/shared/shared/models/__init__.py @@ -3,5 +3,7 @@ "receipt", "product", "voucher", + "voucher_grant_outbox", + "product_voucher_grant", "user", ] diff --git a/apps/shared/shared/models/product_voucher_grant.py b/apps/shared/shared/models/product_voucher_grant.py new file mode 100644 index 0000000..01debcb --- /dev/null +++ b/apps/shared/shared/models/product_voucher_grant.py @@ -0,0 +1,34 @@ +from sqlalchemy import ( + Boolean, + Column, + ForeignKey, + Integer, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, relationship + +from shared.models.base import AutoIdMixin, Base, TimeStampMixin +from shared.models.product import Product + + +class ProductVoucherGrant(AutoIdMixin, TimeStampMixin, Base): + """ + (PLD-1472) 상품 → NCG Voucher(복권) 티켓 매핑. + + 상품이 결제되면 어떤 `ticket_type`(포탈 prizeTables 키: STANDARD/PREMIUM/…)을 몇 장 발급할지. + 한 상품이 여러 종류를 줄 수 있어 (product_id, ticket_type) 별 1행. `active=false`면 발급 제외. + (IAP는 발급 수량만 정하고, 상금표·확률·개봉은 포탈 voucher_policy가 권위.) + """ + + __tablename__ = "product_voucher_grant" + + product_id = Column(Integer, ForeignKey("product.id"), nullable=False) + product: Mapped["Product"] = relationship("Product", foreign_keys=[product_id]) + ticket_type = Column(Text, nullable=False, doc="포탈 prizeTables 키") + count = Column(Integer, nullable=False, default=1, server_default="1") + active = Column(Boolean, nullable=False, default=True, server_default="true") + + __table_args__ = ( + UniqueConstraint("product_id", "ticket_type", name="uq_product_voucher_grant"), + ) diff --git a/apps/shared/shared/models/voucher_grant_outbox.py b/apps/shared/shared/models/voucher_grant_outbox.py new file mode 100644 index 0000000..43fd941 --- /dev/null +++ b/apps/shared/shared/models/voucher_grant_outbox.py @@ -0,0 +1,47 @@ +from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, Text +from sqlalchemy.orm import Mapped, backref, relationship + +from shared.enums import VoucherGrantStatus +from shared.models.base import AutoIdMixin, Base, EnumType, TimeStampMixin +from shared.models.receipt import Receipt + + +class VoucherGrantOutbox(AutoIdMixin, TimeStampMixin, Base): + """ + (PLD-1468) NCG Voucher 아웃박스 — 검증된 결제(Receipt)에 대한 포탈 바우처 발급/회수의 멱등·재시도 추적. + + - receipt_id UNIQUE = 1 결제 = 1 행 (멱등). + - 지급 트리거(worker)가 포탈 grant 호출 후 status=GRANTED. 실패는 attempts++/last_error 기록 후 재시도. + - 리컨사일(beat)이 cutoff 이후 VALID인데 여기 GRANTED가 없는 receipt를 찾아 재호출(포탈 멱등). + - 환불 감지 시 REVOKE_PENDING → 포탈 revoke 성공 후 REVOKED. + + 권위 있는 바우처 상태(등급/개봉/홀드/회수)는 포탈 `purchase_voucher`에 있고, + 이 테이블은 IAP측 "포탈에 넘겼나?" 아웃박스 마커일 뿐이다. (고아 `voucher_request` 재사용 대신 신규 — 옛 스키마 의미·스테일 회피) + """ + + __tablename__ = "voucher_grant_outbox" + + receipt_id = Column(Integer, ForeignKey("receipt.id"), nullable=False, unique=True) + receipt: Mapped["Receipt"] = relationship( + "Receipt", + foreign_keys=[receipt_id], + uselist=False, + backref=backref("voucher_grant_outbox"), + ) + + status = Column( + EnumType(VoucherGrantStatus), + nullable=False, + default=VoucherGrantStatus.PENDING, + server_default=str(VoucherGrantStatus.PENDING.value), # "0" — bulk/upsert/raw insert도 NOT NULL 안전 + ) + portal_ref = Column(Text, nullable=True, doc="포탈 grant 응답 참조(멱등 확인용)") + attempts = Column(Integer, nullable=False, default=0, server_default="0") + last_error = Column(Text, nullable=True) + granted_at = Column(DateTime(timezone=True), nullable=True) + revoked_at = Column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + # 재시도 워커가 미완료(status != GRANTED) 행을 폴링 → status 인덱스. + Index("ix_voucher_grant_outbox_status", "status"), + ) diff --git a/apps/shared/tool/migrations/versions/8db0d254c89d_add_voucher_grant_outbox.py b/apps/shared/tool/migrations/versions/8db0d254c89d_add_voucher_grant_outbox.py new file mode 100644 index 0000000..c576e59 --- /dev/null +++ b/apps/shared/tool/migrations/versions/8db0d254c89d_add_voucher_grant_outbox.py @@ -0,0 +1,43 @@ +"""Add voucher_grant_outbox table (PLD-1468) + +Revision ID: 8db0d254c89d +Revises: b1d5e1dc71ea +Create Date: 2026-08-03 00:00:00 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "8db0d254c89d" +down_revision = "b1d5e1dc71ea" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # (PLD-1468) NCG Voucher 아웃박스. status는 EnumType(VoucherGrantStatus) = Integer 백엔드. + op.create_table( + "voucher_grant_outbox", + sa.Column("receipt_id", sa.Integer(), nullable=False), + sa.Column("status", sa.Integer(), server_default="0", nullable=False), + sa.Column("portal_ref", sa.Text(), nullable=True), + sa.Column("attempts", sa.Integer(), server_default="0", nullable=False), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["receipt_id"], ["receipt.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("receipt_id", name="uq_voucher_grant_outbox_receipt_id"), + ) + op.create_index( + "ix_voucher_grant_outbox_status", "voucher_grant_outbox", ["status"], unique=False + ) + + +def downgrade() -> None: + op.drop_index("ix_voucher_grant_outbox_status", table_name="voucher_grant_outbox") + op.drop_table("voucher_grant_outbox") diff --git a/apps/shared/tool/migrations/versions/9c1e2f3a4b5c_add_product_voucher_grant.py b/apps/shared/tool/migrations/versions/9c1e2f3a4b5c_add_product_voucher_grant.py new file mode 100644 index 0000000..45fcda5 --- /dev/null +++ b/apps/shared/tool/migrations/versions/9c1e2f3a4b5c_add_product_voucher_grant.py @@ -0,0 +1,37 @@ +"""Add product_voucher_grant table (PLD-1472) + +Revision ID: 9c1e2f3a4b5c +Revises: 8db0d254c89d +Create Date: 2026-08-04 00:00:00 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "9c1e2f3a4b5c" +down_revision = "8db0d254c89d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # (PLD-1472) 상품 → 복권 티켓 매핑. (product_id, ticket_type) 별 1행, active=false면 발급 제외. + op.create_table( + "product_voucher_grant", + sa.Column("product_id", sa.Integer(), nullable=False), + sa.Column("ticket_type", sa.Text(), nullable=False), + sa.Column("count", sa.Integer(), server_default="1", nullable=False), + sa.Column("active", sa.Boolean(), server_default="true", nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["product_id"], ["product.id"]), + sa.PrimaryKeyConstraint("id"), + # UNIQUE(product_id, ticket_type) 복합 btree가 product_id 선두 조회를 커버 → 별도 단일 인덱스 불요. + sa.UniqueConstraint("product_id", "ticket_type", name="uq_product_voucher_grant"), + ) + + +def downgrade() -> None: + op.drop_table("product_voucher_grant") diff --git a/apps/worker/app/celery_app.py b/apps/worker/app/celery_app.py index a5a21b6..bc3553f 100644 --- a/apps/worker/app/celery_app.py +++ b/apps/worker/app/celery_app.py @@ -44,6 +44,16 @@ "schedule": crontab(minute="*/60"), "options": {"queue": "background_job_queue"}, }, + "voucher-grant-every-2-minutes": { + "task": "iap.voucher_grant", + "schedule": crontab(minute="*/2"), + "options": {"queue": "background_job_queue"}, + }, + "voucher-reconcile-every-5-minutes": { + "task": "iap.voucher_reconcile", + "schedule": crontab(minute="*/5"), + "options": {"queue": "background_job_queue"}, + }, } app.conf.update( diff --git a/apps/worker/app/config.py b/apps/worker/app/config.py index ad2541b..6d21d39 100644 --- a/apps/worker/app/config.py +++ b/apps/worker/app/config.py @@ -1,4 +1,5 @@ import base64 +from datetime import datetime from typing import Optional from pydantic import AmqpDsn, PostgresDsn, RedisDsn @@ -26,6 +27,14 @@ class Settings(BaseSettings): iap_alert_webhook_url: Optional[str] = None iap_sales_webhook_url: Optional[str] = None + # NCG Voucher 지급 트리거(PLD-1469). 포탈 grant 엔드포인트 + 서버간 JWT(gameBackendApiHandler). + portal_grant_url: Optional[str] = None # 예: https://.../api/voucher/grant + portal_revoke_url: Optional[str] = None # 환불 회수용(PLD-1470) + portal_iap_jwt_secret: Optional[str] = None # 포탈 JWT_IAP_SECRET_KEY와 동일(HS256) + voucher_grant_enabled: bool = False # IAP측 마스터 스위치(포탈 policy.enabled와 별개) + # 이 시각(created_at) 이후 영수증만 바우처 대상(과거 소급 방지). ISO8601 env, 미설정(None)=컷오프 없음. + voucher_grant_cutoff: Optional[datetime] = None + google_credential: Optional[str] = None google_package_dict: dict[PackageName, str] = { PackageName.NINE_CHRONICLES_M: "com.planetariumlabs.ninechroniclesmobile", diff --git a/apps/worker/app/tasks/__init__.py b/apps/worker/app/tasks/__init__.py index 70fb4a2..57186fc 100644 --- a/apps/worker/app/tasks/__init__.py +++ b/apps/worker/app/tasks/__init__.py @@ -4,3 +4,5 @@ from app.tasks.status_monitor import status_monitor from app.tasks.track_google_refund import track_google_refund from app.tasks.tracker import track_tx +from app.tasks.voucher_grant_task import grant_vouchers +from app.tasks.voucher_reconcile_task import reconcile_vouchers diff --git a/apps/worker/app/tasks/track_google_refund.py b/apps/worker/app/tasks/track_google_refund.py index cab0065..08c7a8c 100644 --- a/apps/worker/app/tasks/track_google_refund.py +++ b/apps/worker/app/tasks/track_google_refund.py @@ -9,6 +9,7 @@ from app.celery_app import app from app.config import config +from app.tasks.voucher_reconcile_task import enqueue_revoke_by_order_ids logger = structlog.get_logger(__name__) @@ -74,6 +75,8 @@ def handle(event, context): current_time = datetime.now(timezone.utc) one_hour_ago = current_time - timedelta(hours=1) + refunded_order_ids: list[str] = [] # (PLD-1470) 바우처 회수 큐잉용 + start_time_ms = int(one_hour_ago.timestamp() * 1000) end_time_ms = int(current_time.timestamp() * 1000) @@ -121,11 +124,21 @@ def handle(event, context): logger.info( f"환불 알림 전송: {void.orderId} (환불 시간: {void.voidedTime.isoformat()})" ) + refunded_order_ids.append(void.orderId) logger.info( f"{package_name.value} 패키지에서 {len(voided_purchases)}개의 최근 환불 데이터를 처리했습니다." ) + # (PLD-1470) 환불된 결제의 NCG 바우처 회수 큐잉(아웃박스 REVOKE_PENDING). 알림 흐름과 독립·best-effort. + # google buyer 환불은 receipt.status를 갱신하지 않으므로 이 훅이 유일 신호원. + try: + queued = enqueue_revoke_by_order_ids(refunded_order_ids) + if queued: + logger.info(f"바우처 회수 큐잉 {queued}건") + except Exception as e: # noqa: BLE001 + logger.warning(f"바우처 회수 큐잉 실패(알림은 정상): {e}") + @app.task( name="iap.track_google_refund", diff --git a/apps/worker/app/tasks/voucher_grant_task.py b/apps/worker/app/tasks/voucher_grant_task.py new file mode 100644 index 0000000..21f4bc3 --- /dev/null +++ b/apps/worker/app/tasks/voucher_grant_task.py @@ -0,0 +1,312 @@ +""" +(PLD-1469/1472) NCG Voucher 지급 트리거. + +검증 완료(VALID) + 상품 지급 성공(tx SUCCESS)한 결제를 폴링해 포탈 grant를 호출하는 beat 태스크. +복잡한 send_product handle()을 건드리지 않고 아웃박스(voucher_grant_outbox)로 디커플링 — 멱등·재시도. + +흐름: + (A) enroll: cutoff 이후 VALID+SUCCESS+실스토어 영수증 중 아직 아웃박스 없는 건 → PENDING 아웃박스 생성(레이스는 SAVEPOINT로 흡수). + (B) dispatch: PENDING 아웃박스 → 상태 재검증 → 상품별 tickets 조회 → 포탈 grant 호출 → GRANTED / 재시도(PENDING) / FAILED. + +상태 의미: + PENDING = 미처리/재시도 대상(회복 가능한 실패 포함: 인증·레이트리밋·5xx·가격 미활성). + GRANTED = 포탈 grant 성공(종단). + FAILED = 진짜 종단 실패(환불/무효 영수증, 미등록 planet, 금액 상한 초과 등 재시도 무의미) — 알림 대상. + +멱등: 아웃박스 receipt_id UNIQUE + 포탈 grant 자체가 iapUuid 멱등. 포탈 policy.enabled=false면 'voucher disabled'로 + 반환되며 PENDING 유지(활성화 후 재발급). +""" + +import datetime +from typing import Optional, Tuple + +import jwt +import requests +import structlog +from shared.enums import ReceiptStatus, Store, TxStatus, VoucherGrantStatus +from shared.models.product_voucher_grant import ProductVoucherGrant +from shared.models.receipt import Receipt +from shared.models.voucher_grant_outbox import VoucherGrantOutbox +from sqlalchemy import create_engine, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import scoped_session, sessionmaker + +from app.celery_app import app +from app.config import config + +logger = structlog.get_logger(__name__) + +engine = create_engine( + config.pg_dsn, pool_size=5, max_overflow=10, pool_recycle=3600, pool_pre_ping=True +) + +# 실 결제 스토어 → 바우처 플랫폼. WEB=PC, APPLE/GOOGLE=MOBILE. +_PROD_STORES = {Store.APPLE, Store.GOOGLE, Store.WEB} +_TEST_STORES = {Store.APPLE_TEST, Store.GOOGLE_TEST, Store.WEB_TEST} +_MOBILE_STORES = {Store.APPLE, Store.APPLE_TEST, Store.GOOGLE, Store.GOOGLE_TEST} +_PC_STORES = {Store.WEB, Store.WEB_TEST} + +HTTP_TIMEOUT = 10 +ENROLL_BATCH = 500 +DISPATCH_BATCH = 200 +ALERT_ATTEMPTS = 5 # PENDING이 이 횟수 이상 재시도 중이면 stall로 간주해 경보(미지급 침전 가시화). +# 인증(만료/무효 JWT)·레이트리밋·타임아웃은 회복 가능 → transient(재시도). 그 외 4xx는 종단. +_TRANSIENT_STATUS = {401, 403, 408, 429} + + +def _grantable_stores() -> set: + """바우처 대상 스토어. production에선 실 스토어만, 그 외(dev/staging)엔 샌드박스도 포함(e2e).""" + if config.stage == "production": + return set(_PROD_STORES) + return _PROD_STORES | _TEST_STORES + + +def platform_for_store(store: Store) -> Optional[str]: + """(PLD-1472) 스토어 → 플랫폼. WEB=PC, APPLE/GOOGLE=MOBILE. TEST/REDEEM=None(대상 아님).""" + if store in _PC_STORES: + return "PC" + if store in _MOBILE_STORES: + return "MOBILE" + return None + + +def tickets_for_product(sess, product_id: int) -> list: + """(PLD-1472) 상품 → 복권 티켓 매핑(active). [{"ticketType": str, "count": int}, ...]. 없으면 [].""" + rows = ( + sess.execute( + select(ProductVoucherGrant) + .where( + ProductVoucherGrant.product_id == product_id, + ProductVoucherGrant.active.is_(True), + ) + .order_by(ProductVoucherGrant.ticket_type) + ) + .scalars() + .all() + ) + return [ + {"ticketType": r.ticket_type, "count": r.count} + for r in rows + if r.count and r.count > 0 + ] + + +def _planet_str(planet_id) -> str: + """planet_id(LargeBinary) → hex 문자열('0x...').""" + if isinstance(planet_id, (bytes, bytearray, memoryview)): + return bytes(planet_id).decode() + return str(planet_id) + + +def _make_jwt() -> str: + """포탈 gameBackendApiHandler용 서버간 JWT(HS256, 1분 만료).""" + now = datetime.datetime.now(datetime.timezone.utc) + return jwt.encode( + {"iat": now, "exp": now + datetime.timedelta(minutes=1), "iss": "iap"}, + config.portal_iap_jwt_secret, + algorithm="HS256", + ) + + +def _post_grant(payload: dict) -> Tuple[bool, Optional[str], bool]: + """ + 포탈 grant 호출. 반환 (terminal_ok, ref, transient): + - terminal_ok=True → GRANTED로 종료(success/already granted/amount too small) + - transient=True → 재시도(PENDING 유지): 5xx·인증(401/403)·레이트리밋(429)·타임아웃(408) 또는 'voucher disabled' + - 둘 다 False → FAILED(그 외 4xx = 검증오류, 재시도해도 동일) + """ + resp = requests.post( + config.portal_grant_url, + json=payload, + headers={"Authorization": f"Bearer {_make_jwt()}"}, + timeout=HTTP_TIMEOUT, + ) + if resp.status_code >= 500 or resp.status_code in _TRANSIENT_STATUS: + return False, f"{resp.status_code}", True + if resp.status_code != 200: + return False, f"{resp.status_code}:{resp.text[:200]}", False + body = resp.json() + if not isinstance(body, dict): + return False, f"unexpected body: {str(body)[:100]}", False + if body.get("message") == "voucher disabled": + return False, None, True # 킬스위치 off — 활성화 후 재발급 + return True, f"granted={body.get('granted')}", False + + +def _alert(text: str) -> None: + """운영 알림(best-effort). 실패해도 태스크 진행을 막지 않음.""" + url = config.iap_alert_webhook_url + if not url: + return + try: + requests.post(url, json={"text": text}, timeout=HTTP_TIMEOUT) + except Exception as e: # noqa: BLE001 + logger.warning("voucher grant alert failed", error=str(e)) + + +@app.task( + name="iap.voucher_grant", + bind=True, + acks_late=True, + queue="background_job_queue", +) +def grant_vouchers(self): + """검증된 결제에 대한 포탈 바우처 발급 트리거(beat, */2분).""" + if not config.voucher_grant_enabled: + return "voucher grant disabled" + if not (config.portal_grant_url and config.portal_iap_jwt_secret): + logger.warning("voucher grant not configured (url/secret missing)") + return "not configured" + + grantable = _grantable_stores() + sess = scoped_session(sessionmaker(bind=engine)) + enrolled = granted = failed = 0 + try: + # (A) enroll — 적격 영수증(실스토어) 중 아웃박스 없는 건을 PENDING으로. + # ⚠️ 스토어 필터를 SQL에 둠: skip 대상(REDEEM 등)을 Python에서 거르면 아웃박스가 안 생겨 + # 매 회차 재조회되어 limit 윈도우를 침전·starve시킨다(리뷰 지적). + conditions = [ + Receipt.status == ReceiptStatus.VALID, + Receipt.tx_status == TxStatus.SUCCESS, + Receipt.product_id.isnot(None), + Receipt.store.in_(grantable), + # 바우처 발급 대상 상품만(active 티켓 매핑 존재) — 미대상 상품이 윈도우 침전하지 않게. + Receipt.product_id.in_( + select(ProductVoucherGrant.product_id).where( + ProductVoucherGrant.active.is_(True) + ) + ), + Receipt.id.notin_(select(VoucherGrantOutbox.receipt_id)), + ] + # 타임스탬프 컷오프(설정 시): 이 시각(created_at) 이후 결제만 대상 — 과거 소급 방지. + if config.voucher_grant_cutoff is not None: + conditions.append(Receipt.created_at >= config.voucher_grant_cutoff) + eligible = ( + sess.execute( + select(Receipt).where(*conditions).order_by(Receipt.id).limit(ENROLL_BATCH) + ) + .scalars() + .all() + ) + for r in eligible: + try: + with sess.begin_nested(): # SAVEPOINT — 레이스 시 이 행만 롤백 + sess.add(VoucherGrantOutbox(receipt_id=r.id)) + enrolled += 1 + except IntegrityError: + pass # 이미 등록됨(동시 실행) — 무시 + sess.commit() + + # (B) dispatch — PENDING 아웃박스를 포탈 grant로. 행 잠금(skip_locked)으로 동시 실행 중복 방지. + pendings = ( + sess.execute( + select(VoucherGrantOutbox) + .where(VoucherGrantOutbox.status == VoucherGrantStatus.PENDING) + .order_by(VoucherGrantOutbox.receipt_id) + .limit(DISPATCH_BATCH) + .with_for_update(skip_locked=True) + ) + .scalars() + .all() + ) + for ob in pendings: + # 행별 격리 — 한 건의 예상외 예외가 배치 전체를 롤백/중단시키지 않게. + try: + r = sess.scalar(select(Receipt).where(Receipt.id == ob.receipt_id)) + if r is None: + ob.status = VoucherGrantStatus.FAILED + ob.last_error = "receipt not found" + failed += 1 + continue + + # dispatch 시점 상태 재검증 — enroll 이후 환불/무효 전이 시 발급 금지(종단). + if r.status != ReceiptStatus.VALID or r.tx_status != TxStatus.SUCCESS: + ob.status = VoucherGrantStatus.FAILED + ob.last_error = ( + f"receipt no longer grantable: status={r.status} tx={r.tx_status}" + ) + failed += 1 + continue + + platform = platform_for_store(Store(r.store)) + if platform is None: + # 실스토어 아님(설정/데이터 이상) — enroll 필터상 도달 어려우나 방어적 종단. + ob.status = VoucherGrantStatus.FAILED + ob.last_error = f"non-grantable store: {r.store}" + failed += 1 + continue + + tickets = ( + tickets_for_product(sess, r.product_id) if r.product_id else [] + ) + if not tickets: + # 티켓 매핑이 아직 미설정/비활성일 수 있음 → 종단 아닌 재시도(PENDING 유지). + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = "no active voucher ticket mapping (retry)" + continue + + payload = { + "iapUuid": str(r.uuid), + "receiptId": r.id, + "agentAddress": r.agent_addr, + "planetId": _planet_str(r.planet_id), + "tickets": tickets, + "platform": platform, # 통계용 + "purchasedAt": (r.purchased_at or r.created_at).isoformat(), + } + try: + ok, ref, transient = _post_grant(payload) + except requests.RequestException as e: + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = f"http error: {e}"[:500] + continue # transient — PENDING 유지 + + if ok: + ob.status = VoucherGrantStatus.GRANTED + ob.portal_ref = ref + ob.granted_at = datetime.datetime.now(datetime.timezone.utc) + granted += 1 + elif transient: + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = ( + f"transient (retry): {ref}" if ref else "transient (retry)" + ) + else: + ob.status = VoucherGrantStatus.FAILED + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = ref or "grant failed" + failed += 1 + except Exception as e: # noqa: BLE001 + # 예상외 예외(비정상 데이터 등) — 이 행만 재시도로 남기고 배치는 계속. + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = f"unexpected: {e}"[:500] + sess.commit() + + # stall 경보 — transient(5xx/인증/티켓매핑 미설정)로 무한 재시도 중인 PENDING은 failed에 안 잡히므로 + # attempts 임계 이상 PENDING 백로그를 별도 집계해 알림(결제 유효 유저의 미지급 침전 가시화). + stalled = ( + sess.scalar( + select(func.count()) + .select_from(VoucherGrantOutbox) + .where( + VoucherGrantOutbox.status == VoucherGrantStatus.PENDING, + VoucherGrantOutbox.attempts >= ALERT_ATTEMPTS, + ) + ) + or 0 + ) + + result = f"enrolled={enrolled} granted={granted} failed={failed} stalled={stalled}" + logger.info("voucher grant run", result=result) + if failed > 0 or stalled > 0: + # FAILED(종단·무재시도) + stall(무한 재시도) — 미지급이 침묵으로 굳지 않도록 알림. + _alert( + f"[voucher-grant] 미지급 주의: failed={failed} " + f"stalled(attempts>={ALERT_ATTEMPTS})={stalled}. {result}" + ) + return result + except Exception: + sess.rollback() + raise + finally: + sess.remove() diff --git a/apps/worker/app/tasks/voucher_reconcile_task.py b/apps/worker/app/tasks/voucher_reconcile_task.py new file mode 100644 index 0000000..9d1144b --- /dev/null +++ b/apps/worker/app/tasks/voucher_reconcile_task.py @@ -0,0 +1,309 @@ +""" +(PLD-1470/1471) NCG Voucher 회수/리컨사일. + +환불 감지 → 포탈 revoke 호출을 아웃박스(voucher_grant_outbox)를 단일 조율점으로 처리. + +두 갈래로 REVOKE_PENDING이 큐잉된다: + - google buyer 환불: track_google_refund가 void 감지 시 `enqueue_revoke_by_order_id`로 큐잉 + (google 환불은 receipt.status를 갱신하지 않으므로 훅이 유일 신호원). + - admin 환불/무효(status=REFUNDED_*/INVALID): 이 태스크의 status 기반 enroll이 잡음. + +아웃박스가 단일 조율점인 이유: + - REVOKE_PENDING/REVOKED 행은 grant enroll의 notin_에 걸려 재등록 안 되고, grant dispatch(PENDING만)도 안 탄다 + → 환불이 grant보다 먼저 도착해도(REVOKE_PENDING로 생성) 발급이 선점적으로 차단된다. + - revoke dispatch가 REVOKE_PENDING → 포탈 revoke → REVOKED. + +멱등: 포탈 revoke 자체가 iapUuid 기준 멱등(미개봉만 회수, 개봉건은 skippedOpened+경보). 재시도 안전. + +⚠️ known gap: Apple buyer 셀프 환불은 현재 신호 경로가 없다(google void tracker만 존재, status도 미갱신). + Apple 환불 회수는 App Store Server Notifications 처리기 도입 시 같은 enqueue_revoke_for_receipt로 연결 필요. +""" + +import datetime +from typing import Optional, Tuple + +import jwt +import requests +import structlog +from shared.enums import ReceiptStatus, Store, VoucherGrantStatus +from shared.models.receipt import Receipt +from shared.models.voucher_grant_outbox import VoucherGrantOutbox +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import scoped_session, sessionmaker + +from app.celery_app import app +from app.config import config + +# grant 태스크와 엔진 공유 — 프로세스당 커넥션 풀 이중 생성 방지(커넥션 예산). +from app.tasks.voucher_grant_task import engine + +logger = structlog.get_logger(__name__) + +HTTP_TIMEOUT = 10 +REVOKE_BATCH = 200 +ENROLL_BATCH = 500 +ALERT_ATTEMPTS = 5 # REVOKE_PENDING이 이 횟수 이상 재시도 중이면 스톨로 간주해 경보(회수 유실 진행중). +_TRANSIENT_STATUS = {401, 403, 408, 429} +# 환불/무효 — 발급된 바우처를 회수해야 하는 receipt 상태. +_REFUNDED_STATUSES = [ + ReceiptStatus.REFUNDED_BY_ADMIN, + ReceiptStatus.REFUNDED_BY_BUYER, + ReceiptStatus.INVALID, +] +# google void → receipt 매칭 시 좁힐 스토어(order_id는 (store,order_id)로만 유일 → 크로스-스토어 오매칭 방지). +_GOOGLE_STORES = [Store.GOOGLE, Store.GOOGLE_TEST] +# 회수 대상 = 발급됐을 수 있는 모든 비-종단 상태 + FAILED(크래시창에 발급 후 FAILED 찍힌 건 포함). +_REVOCABLE_OUTBOX_STATUSES = [ + VoucherGrantStatus.PENDING, + VoucherGrantStatus.GRANTED, + VoucherGrantStatus.FAILED, +] + + +def _make_jwt() -> str: + """포탈 gameBackendApiHandler용 서버간 JWT(HS256, 1분 만료).""" + now = datetime.datetime.now(datetime.timezone.utc) + return jwt.encode( + {"iat": now, "exp": now + datetime.timedelta(minutes=1), "iss": "iap"}, + config.portal_iap_jwt_secret, + algorithm="HS256", + ) + + +def _post_revoke(iap_uuid: str) -> Tuple[bool, Optional[str], bool]: + """ + 포탈 revoke 호출. 반환 (ok, ref, transient): + - ok=True → REVOKED로 종료 + - transient → 재시도(REVOKE_PENDING 유지): 5xx·인증·레이트리밋·타임아웃 + - 둘 다 False → 재시도 무의미한 오류(4xx). revoke는 유실이 곧 미회수(환불 NCG 잔존)이므로 + 드롭하지 않고 REVOKE_PENDING 유지 + 경보로 사람 개입 유도(상위에서 처리). + """ + resp = requests.post( + config.portal_revoke_url, + json={"iapUuid": iap_uuid}, + headers={"Authorization": f"Bearer {_make_jwt()}"}, + timeout=HTTP_TIMEOUT, + ) + if resp.status_code >= 500 or resp.status_code in _TRANSIENT_STATUS: + return False, f"{resp.status_code}", True + if resp.status_code != 200: + return False, f"{resp.status_code}:{resp.text[:200]}", False + body = resp.json() + if not isinstance(body, dict): + return False, f"unexpected body: {str(body)[:100]}", False + skipped = body.get("skippedOpened") or [] + ref = ( + f"revoked={body.get('revoked')} already={body.get('alreadyRevoked')} " + f"skippedOpened={skipped}" + ) + return True, ref, False + + +def _alert(text: str) -> None: + url = config.iap_alert_webhook_url + if not url: + return + try: + requests.post(url, json={"text": text}, timeout=HTTP_TIMEOUT) + except Exception as e: # noqa: BLE001 + logger.warning("voucher reconcile alert failed", error=str(e)) + + +def enqueue_revoke_for_receipt(sess, receipt_id: int) -> bool: + """ + 환불된 결제의 아웃박스를 REVOKE_PENDING으로. 없으면 생성(grant 선점). 멱등. + - 아웃박스 없음: REVOKE_PENDING으로 생성 → grant enroll(notin_)·dispatch(PENDING)가 모두 스킵 → 발급 선점 차단. + - PENDING/GRANTED: REVOKE_PENDING으로 전이. + - REVOKED/REVOKE_PENDING: no-op. + 반환: 큐잉(변경/생성) 여부. + """ + ob = sess.scalar( + select(VoucherGrantOutbox).where(VoucherGrantOutbox.receipt_id == receipt_id) + ) + if ob is None: + # 신규 생성. grant enroll이 같은 receipt_id를 동시에 INSERT할 수 있으므로 SAVEPOINT로 격리 — + # 충돌 없으면 REVOKE_PENDING 생성, 충돌(grant가 선점)이면 재조회 후 전이(배치 통째 롤백 방지). + try: + with sess.begin_nested(): + sess.add( + VoucherGrantOutbox( + receipt_id=receipt_id, status=VoucherGrantStatus.REVOKE_PENDING + ) + ) + return True + except IntegrityError: + ob = sess.scalar( + select(VoucherGrantOutbox).where( + VoucherGrantOutbox.receipt_id == receipt_id + ) + ) + if ob is None: # 이론상 도달 불가 + return False + if ob.status in (VoucherGrantStatus.REVOKED, VoucherGrantStatus.REVOKE_PENDING): + return False + ob.status = VoucherGrantStatus.REVOKE_PENDING + return True + + +def enqueue_revoke_by_order_id(sess, order_id: str) -> bool: + """ + google void의 order_id로 receipt 찾아 revoke 큐잉. 반환: 하나라도 큐잉되면 True. + ⚠️ order_id는 (store, order_id)로만 유일 → 스토어를 google 계열로 좁혀 크로스-스토어 오매칭 방지. + (다른 스토어의 동일 order_id를 잘못 회수하면 정상 유저 손해). 다중 매치는 모두 큐잉. + """ + receipts = ( + sess.execute( + select(Receipt).where( + Receipt.order_id == order_id, + Receipt.store.in_(_GOOGLE_STORES), + ) + ) + .scalars() + .all() + ) + queued = False + for r in receipts: + if enqueue_revoke_for_receipt(sess, r.id): + queued = True + return queued + + +def enqueue_revoke_by_order_ids(order_ids) -> int: + """ + 여러 order_id에 대해 revoke 큐잉(자체 세션 관리). track_google_refund 환불 감지 훅용. + voucher_grant_enabled=False면 no-op(0). 한 건 실패가 나머지를 막지 않음. + """ + if not config.voucher_grant_enabled or not order_ids: + return 0 + sess = scoped_session(sessionmaker(bind=engine)) + n = 0 + try: + for oid in order_ids: + try: + if enqueue_revoke_by_order_id(sess, oid): + n += 1 + except Exception as e: # noqa: BLE001 + logger.warning("enqueue revoke failed", order_id=oid, error=str(e)) + sess.commit() + if n: + logger.info("refund → revoke queued", count=n) + except Exception: + sess.rollback() + raise + finally: + sess.remove() + return n + + +@app.task( + name="iap.voucher_reconcile", + bind=True, + acks_late=True, + queue="background_job_queue", +) +def reconcile_vouchers(self): + """환불/무효 결제의 바우처 회수(beat, */5분).""" + if not config.voucher_grant_enabled: + return "voucher grant disabled" + if not (config.portal_revoke_url and config.portal_iap_jwt_secret): + logger.warning("voucher revoke not configured (url/secret missing)") + return "not configured" + + sess = scoped_session(sessionmaker(bind=engine)) + enqueued = revoked = failed = 0 + try: + # (A) status 기반 enroll — 환불/무효 receipt를 가진 미회수 아웃박스 → REVOKE_PENDING. + # (google buyer 환불은 status 미갱신이라 track_google_refund 훅이 담당; 여기선 admin 환불 등.) + rows = ( + sess.execute( + select(VoucherGrantOutbox) + .join(Receipt, Receipt.id == VoucherGrantOutbox.receipt_id) + .where( + # FAILED 포함: grant가 HTTP 200(발급)後 commit前 크래시→재전달 사이 admin 환불 시 + # 재dispatch가 FAILED로 찍는데 바우처는 이미 발급됨 → 회수 누락 방지(revoke는 멱등이라 + # 미발급 receipt엔 no-op으로 무해). + VoucherGrantOutbox.status.in_(_REVOCABLE_OUTBOX_STATUSES), + Receipt.status.in_(_REFUNDED_STATUSES), + ) + .limit(ENROLL_BATCH) + ) + .scalars() + .all() + ) + for ob in rows: + ob.status = VoucherGrantStatus.REVOKE_PENDING + enqueued += 1 + sess.commit() + + # (B) revoke dispatch — REVOKE_PENDING → 포탈 revoke. 행 잠금으로 동시 실행 중복 방지. + pendings = ( + sess.execute( + select(VoucherGrantOutbox) + .where(VoucherGrantOutbox.status == VoucherGrantStatus.REVOKE_PENDING) + # attempts 오름차순 우선 — 영구 4xx 등 고-attempts 행이 batch 앞을 막아 + # 신규 회수가 starve되지 않게(신규 attempts=0 먼저 처리). + .order_by(VoucherGrantOutbox.attempts, VoucherGrantOutbox.receipt_id) + .limit(REVOKE_BATCH) + .with_for_update(skip_locked=True) + ) + .scalars() + .all() + ) + for ob in pendings: + try: + r = sess.scalar(select(Receipt).where(Receipt.id == ob.receipt_id)) + if r is None: + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = "receipt not found (revoke)" + continue + try: + ok, ref, transient = _post_revoke(str(r.uuid)) + except requests.RequestException as e: + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = f"http error: {e}"[:500] + continue + + if ok: + ob.status = VoucherGrantStatus.REVOKED + ob.revoked_at = datetime.datetime.now(datetime.timezone.utc) + ob.portal_ref = ref + revoked += 1 + else: + # transient/4xx 모두 REVOKE_PENDING 유지(회수 유실=환불 NCG 잔존이므로 드롭 금지). + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = f"{'transient' if transient else 'error'}: {ref}" + if not transient: + failed += 1 + except Exception as e: # noqa: BLE001 + ob.attempts = (ob.attempts or 0) + 1 + ob.last_error = f"unexpected: {e}"[:500] + sess.commit() + + # 스톨 경보 — transient(5xx/인증/레이트리밋)로 무한 재시도 중인 회수는 failed에 안 잡히므로 + # attempts 임계 이상 REVOKE_PENDING 백로그를 별도 집계해 알림(회수 유실이 진행 중일 수 있음). + stalled = ( + sess.scalar( + select(func.count()) + .select_from(VoucherGrantOutbox) + .where( + VoucherGrantOutbox.status == VoucherGrantStatus.REVOKE_PENDING, + VoucherGrantOutbox.attempts >= ALERT_ATTEMPTS, + ) + ) + or 0 + ) + + result = f"enqueued={enqueued} revoked={revoked} failed={failed} stalled={stalled}" + logger.info("voucher reconcile run", result=result) + if failed > 0 or stalled > 0: + _alert( + f"[voucher-revoke] 회수 실패/스톨 (수동 검토): failed={failed} " + f"stalled(attempts>={ALERT_ATTEMPTS})={stalled}. {result}" + ) + return result + except Exception: + sess.rollback() + raise + finally: + sess.remove() diff --git a/apps/worker/tests/test_voucher_grant_task.py b/apps/worker/tests/test_voucher_grant_task.py new file mode 100644 index 0000000..dfc828e --- /dev/null +++ b/apps/worker/tests/test_voucher_grant_task.py @@ -0,0 +1,150 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.tasks import voucher_grant_task as vg +from shared.enums import Store + + +class TestPlatformForStore: + @pytest.mark.parametrize( + "store,expected", + [ + (Store.WEB, "PC"), + (Store.WEB_TEST, "PC"), + (Store.APPLE, "MOBILE"), + (Store.APPLE_TEST, "MOBILE"), + (Store.GOOGLE, "MOBILE"), + (Store.GOOGLE_TEST, "MOBILE"), + (Store.TEST, None), # 디버그 — 바우처 대상 아님 + (Store.REDEEM, None), # 코드 리딤 — 결제 아님 + ], + ) + def test_mapping(self, store, expected): + assert vg.platform_for_store(store) == expected + + +class TestPlanetStr: + def test_bytes(self): + assert vg._planet_str(b"0x000000000000") == "0x000000000000" + + def test_memoryview(self): + assert vg._planet_str(memoryview(b"0x000000000001")) == "0x000000000001" + + def test_str_passthrough(self): + assert vg._planet_str("0x000000000000") == "0x000000000000" + + +class TestTicketsForProduct: + def _sess_with(self, rows): + sess = MagicMock() + sess.execute.return_value.scalars.return_value.all.return_value = rows + return sess + + def _row(self, ticket_type, count): + r = MagicMock() + r.ticket_type = ticket_type + r.count = count + return r + + def test_returns_ticket_list(self): + sess = self._sess_with([self._row("STANDARD", 3), self._row("PREMIUM", 1)]) + assert vg.tickets_for_product(sess, 1) == [ + {"ticketType": "STANDARD", "count": 3}, + {"ticketType": "PREMIUM", "count": 1}, + ] + + def test_empty_when_no_mapping(self): + assert vg.tickets_for_product(self._sess_with([]), 1) == [] + + def test_skips_nonpositive_count(self): + sess = self._sess_with([self._row("STANDARD", 0), self._row("PREMIUM", 2)]) + assert vg.tickets_for_product(sess, 1) == [{"ticketType": "PREMIUM", "count": 2}] + + +class TestPostGrant: + """포탈 grant 응답 분류: (terminal_ok, ref, transient).""" + + def _resp(self, status, body=None): + r = MagicMock() + r.status_code = status + r.text = "err-body" + r.json = MagicMock(return_value=body if body is not None else {}) + return r + + def _run(self, resp): + with patch.object(vg.config, "portal_grant_url", "http://portal/api/voucher/grant"), \ + patch.object(vg.config, "portal_iap_jwt_secret", "secret"), \ + patch.object(vg.requests, "post", return_value=resp) as post: + return vg._post_grant({"iapUuid": "x"}), post + + def test_success_is_terminal(self): + (ok, ref, transient), post = self._run( + self._resp(200, {"message": "success", "granted": 2}) + ) + assert ok is True and transient is False and "granted=2" in ref + # Authorization: Bearer 헤더 부착 확인 + assert post.call_args.kwargs["headers"]["Authorization"].startswith("Bearer ") + + def test_already_granted_is_terminal(self): + (ok, ref, transient), _ = self._run( + self._resp(200, {"message": "already granted", "granted": 3}) + ) + assert ok is True and transient is False + + def test_amount_too_small_is_terminal(self): + (ok, ref, transient), _ = self._run( + self._resp(200, {"message": "no voucher (amount too small)", "granted": 0}) + ) + assert ok is True and transient is False + + def test_voucher_disabled_is_transient(self): + # 킬스위치 off — 활성화 후 재발급해야 하므로 재시도(PENDING 유지). + (ok, ref, transient), _ = self._run( + self._resp(200, {"message": "voucher disabled", "granted": 0}) + ) + assert ok is False and transient is True + + def test_5xx_is_transient(self): + (ok, ref, transient), _ = self._run(self._resp(500)) + assert ok is False and transient is True + + def test_auth_and_ratelimit_are_transient(self): + # 인증(401/403)·레이트리밋(429)·타임아웃(408)은 회복 가능 → 재시도(종단 아님). + for status in (401, 403, 408, 429): + (ok, ref, transient), _ = self._run(self._resp(status)) + assert ok is False and transient is True, f"status {status} should be transient" + + def test_4xx_is_terminal_failure(self): + # 검증오류(400 등) — 재시도해도 동일 → FAILED. + (ok, ref, transient), _ = self._run(self._resp(400, {})) + assert ok is False and transient is False and ref.startswith("400") + + def test_non_object_body_is_terminal(self): + (ok, ref, transient), _ = self._run(self._resp(200, [1, 2, 3])) + assert ok is False and transient is False + + +class TestGrantableStores: + def test_production_excludes_sandbox(self): + with patch.object(vg.config, "stage", "production"): + s = vg._grantable_stores() + assert Store.APPLE in s and Store.GOOGLE in s and Store.WEB in s + assert Store.APPLE_TEST not in s and Store.WEB_TEST not in s + + def test_nonprod_includes_sandbox(self): + with patch.object(vg.config, "stage", "development"): + s = vg._grantable_stores() + assert Store.APPLE_TEST in s and Store.WEB_TEST in s and Store.GOOGLE_TEST in s + + +class TestGrantVouchersGating: + def test_disabled_returns_early(self): + with patch.object(vg.config, "voucher_grant_enabled", False): + assert vg.grant_vouchers.run() == "voucher grant disabled" + + def test_not_configured_returns_early(self): + with patch.object(vg.config, "voucher_grant_enabled", True), \ + patch.object(vg.config, "portal_grant_url", None), \ + patch.object(vg.config, "portal_iap_jwt_secret", None): + assert vg.grant_vouchers.run() == "not configured" diff --git a/apps/worker/tests/test_voucher_reconcile_task.py b/apps/worker/tests/test_voucher_reconcile_task.py new file mode 100644 index 0000000..2348516 --- /dev/null +++ b/apps/worker/tests/test_voucher_reconcile_task.py @@ -0,0 +1,133 @@ +from unittest.mock import MagicMock, patch + +from app.tasks import voucher_reconcile_task as vr +from shared.enums import VoucherGrantStatus + + +class TestPostRevoke: + def _resp(self, status, body=None): + r = MagicMock() + r.status_code = status + r.text = "err" + r.json = MagicMock(return_value=body if body is not None else {}) + return r + + def _run(self, resp): + with patch.object(vr.config, "portal_revoke_url", "http://portal/api/voucher/revoke"), \ + patch.object(vr.config, "portal_iap_jwt_secret", "secret"), \ + patch.object(vr.requests, "post", return_value=resp): + return vr._post_revoke("some-uuid") + + def test_success(self): + ok, ref, transient = self._run( + self._resp(200, {"revoked": 2, "alreadyRevoked": 0, "skippedOpened": []}) + ) + assert ok is True and transient is False and "revoked=2" in ref + + def test_skipped_opened_surfaced_in_ref(self): + ok, ref, transient = self._run( + self._resp(200, {"revoked": 1, "alreadyRevoked": 0, "skippedOpened": [7]}) + ) + assert ok is True and "skippedOpened=[7]" in ref + + def test_5xx_transient(self): + ok, ref, transient = self._run(self._resp(503)) + assert ok is False and transient is True + + def test_auth_ratelimit_transient(self): + for status in (401, 403, 408, 429): + ok, ref, transient = self._run(self._resp(status)) + assert ok is False and transient is True + + def test_4xx_non_transient(self): + ok, ref, transient = self._run(self._resp(400, {})) + assert ok is False and transient is False + + def test_non_object_body_non_transient(self): + ok, ref, transient = self._run(self._resp(200, "oops")) + assert ok is False and transient is False + + +class TestEnqueueRevokeForReceipt: + def test_creates_when_absent(self): + sess = MagicMock() + sess.scalar.return_value = None + added = {} + sess.add.side_effect = lambda ob: added.setdefault("ob", ob) + assert vr.enqueue_revoke_for_receipt(sess, 42) is True + assert added["ob"].receipt_id == 42 + assert added["ob"].status == VoucherGrantStatus.REVOKE_PENDING + + def test_transitions_granted_to_revoke_pending(self): + sess = MagicMock() + ob = MagicMock() + ob.status = VoucherGrantStatus.GRANTED + sess.scalar.return_value = ob + assert vr.enqueue_revoke_for_receipt(sess, 42) is True + assert ob.status == VoucherGrantStatus.REVOKE_PENDING + sess.add.assert_not_called() + + def test_transitions_pending_to_revoke_pending(self): + sess = MagicMock() + ob = MagicMock() + ob.status = VoucherGrantStatus.PENDING + sess.scalar.return_value = ob + assert vr.enqueue_revoke_for_receipt(sess, 42) is True + assert ob.status == VoucherGrantStatus.REVOKE_PENDING + + def test_transitions_failed_to_revoke_pending(self): + # 🔴#2: 크래시창에 발급됐는데 FAILED 찍힌 건도 회수 대상 → 전이돼야 함. + sess = MagicMock() + ob = MagicMock() + ob.status = VoucherGrantStatus.FAILED + sess.scalar.return_value = ob + assert vr.enqueue_revoke_for_receipt(sess, 42) is True + assert ob.status == VoucherGrantStatus.REVOKE_PENDING + + def test_noop_when_already_revoked(self): + for st in (VoucherGrantStatus.REVOKED, VoucherGrantStatus.REVOKE_PENDING): + sess = MagicMock() + ob = MagicMock() + ob.status = st + sess.scalar.return_value = ob + assert vr.enqueue_revoke_for_receipt(sess, 42) is False + assert ob.status == st # 변경 없음 + + +class TestEnqueueByOrderId: + def test_handles_multiple_google_matches(self): + # order_id가 (store,order_id)로만 유일 → 다중 매치 시 모두 큐잉. + sess = MagicMock() + r1, r2 = MagicMock(), MagicMock() + r1.id, r2.id = 1, 2 + sess.execute.return_value.scalars.return_value.all.return_value = [r1, r2] + with patch.object(vr, "enqueue_revoke_for_receipt", return_value=True) as m: + assert vr.enqueue_revoke_by_order_id(sess, "order-x") is True + assert m.call_count == 2 + + def test_no_match_returns_false(self): + sess = MagicMock() + sess.execute.return_value.scalars.return_value.all.return_value = [] + assert vr.enqueue_revoke_by_order_id(sess, "order-x") is False + + +class TestReconcileGating: + def test_disabled_returns_early(self): + with patch.object(vr.config, "voucher_grant_enabled", False): + assert vr.reconcile_vouchers.run() == "voucher grant disabled" + + def test_not_configured_returns_early(self): + with patch.object(vr.config, "voucher_grant_enabled", True), \ + patch.object(vr.config, "portal_revoke_url", None), \ + patch.object(vr.config, "portal_iap_jwt_secret", None): + assert vr.reconcile_vouchers.run() == "not configured" + + +class TestEnqueueByOrderIds: + def test_noop_when_disabled(self): + with patch.object(vr.config, "voucher_grant_enabled", False): + assert vr.enqueue_revoke_by_order_ids(["o1", "o2"]) == 0 + + def test_noop_when_empty(self): + with patch.object(vr.config, "voucher_grant_enabled", True): + assert vr.enqueue_revoke_by_order_ids([]) == 0