영수증 관련 문제

고객님의 문의에 답변하는 직원은 고객 여러분의 가족 중 한 사람일 수 있습니다.
고객의 언어폭력(비하, 조롱, 욕설, 협박, 성희롱 등)으로부터 직원을 보호하기 위해
관련 법에 따라 수사기관에 필요한 조치를 요구할 수 있으며, 형법에 의해 처벌 대상이 될 수 있습니다.

커뮤니티 이용 정책에 위배되는 게시물을 작성할 경우, 별도 안내 없이 게시물 삭제 또는 커뮤니티 이용이 제한될 수 있습니다.

문의 응대 : 평일 오전 10시 ~ 오후 6시
문의를 남기실 경우 다음 항목을 작성해 주세요.
정보가 부족하거나 응대시간 외 문의하는 경우 확인 및 답변이 지연될 수 있습니다.

  • 뒤끝 SDK 버전 : 5.14.0
  • 프로젝트명 : 난세표류기 - 한국사 방치형 RPG
  • 스테이터스 코드 :
  • 에러 코드 :
  • 에러 메시지 :

안녕하세요. 다름이 아니라 인앱결제 부분에서

문제가 있는 유저가 있어서 연락드렸습니다!

어제 11시 45분 쯤에 상품 결제후 미지급처리됐다고 해서
영수증 조회 해보니 구글에는 결제영수증이 있는데
뒤끝 영수증 검사쪽에는 문제가 없어서 우편으로 해당 재화를 보냈습니다.

그리고 그 유저는 백그라운드 상태에서 다시
게임에 들어갔는데 재화가 열리는 창이 열리면서
어제 구매한 상품이 또 들어왔다고 합니다.

그리고 오늘 아침 2024-10-08 20:29에 영수증 검증에
영수증이 찍혀 있습니다.
이 부분은 왜그런것이고 어떻게 해결해야할까요?

유저 아이디 : -

며칠전에 한 유저가 상품 구매를 했는데 들어오지 않았다고하였습니다.

그 과정이 ‘이미 보유중인 아이템입니다.’
라며 다시 재구매도 안되었다고 합니다.

그래서 구글쪽 영수증 조회 정말로 상품을 구매했고,
뒤끝 영수증 검증은 찍혔지만…
아이템이 들어오지 않은 것을 확인했습니다.
그래서 다시 우편으로 해당 상품을 전달했습니다.

그런데 자신은 아무런 행동을 안했는데
3일 뒤에 해당유저가 환불 처리 되었다고
말해줬습니다.

그리고 지금 확인해보니 영수증검사가
추가로 들어와있는데 똑같은 상품이 영수증 검증 실패라고
항목이 들어와 있습니다.

이 부분은 왜그런것이고 어떻게 해결해야할까요?

유저 아이디 : -

추가로 이상한 유저가 있는데…
해당 유저도 아이템을 미지급 받아서
따로 해당 상품을 우편으로 보내주었는데
해당 유저의 영수증 검사를 보면
pass_grow_1 상품이 영수증 검사의 상태가
실패로 뜨는 것을 확인했습니다.

이 부분은 왜그런것이고 어떻게 해결해야할까요?

유저 아이디 : -

안녕하세요 개발자님,
문의해주신 내용 관련하여 답변드리기 이전 확인이 필요한 정보가 있어 요청드립니다.
아래 안내드리는 내용의 확인 부탁드립니다.

  1. 세 유저가 구매한 아이템이 구글 인앱상품에서 소모성으로 설정되어있는지 비소모성으로 설정되어 있는지
  2. 사용하고 계신 IAP 결제 모듈(ex: Unity IAP 사용)
  3. 구현하신 구매 로직의 개략적인 처리 flow와 구매실패에 대한 복구 로직이 있는지 여부

구글과 원스토어 동시 출시한 상태입니다.

  1. 소모성으로 설정되어 있습니다. (일부유저에게만 저런 현상이 벌어집니다.)
  2. 구글은 Unity IAP사용중, 원스토어는 OneIAP최신버전 사용중.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

using UnityEngine.Purchasing;   // 구글
using OneStore.Purchasing;      // 원스토어

using BackEnd;
using System;

public class IAPManager : MonoBehaviour, IStoreListener, IPurchaseCallback
{
	// 인앱 결제 제품의 ID
	private string[] productIds =
		{   "ads_remove",

			"stage_pakage_1", "stage_pakage_2", "stage_pakage_3", "stage_pakage_4", "stage_pakage_5",
			"stage_pakage_6", "stage_pakage_7", "stage_pakage_8", "stage_pakage_9", "stage_pakage_10",
			"stage_pakage_11", "stage_pakage_12", "stage_pakage_13", "stage_pakage_14", "stage_pakage_15",
			"stage_pakage_16", "stage_pakage_17", "stage_pakage_18", "stage_pakage_19", "stage_pakage_20",
			"stage_pakage_21", "stage_pakage_22", "stage_pakage_23", "stage_pakage_24",

			"daily_pakage_1", "daily_pakage_2",
			"weekly_pakage_1", "weekly_pakage_2",
			"monthly_pakage_1", "monthly_pakage_2",

			"pass_level_1", "pass_stage_1", "pass_hunt_1", "pass_grow_1",
			"pass_level_2", "pass_stage_2", "pass_hunt_2", "pass_grow_2",

			"diamond_1", "diamond_2", "diamond_3", "diamond_4", "diamond_5"
		};

	//// 구글 용
	private static IStoreController storeController;
	private static IExtensionProvider extensionProvider;


	//// 원스토어 용
	// input your license key
	private string _licenseKey = ConstantsAKR.PublicKey;
	// 이 객체를 통해 OneStore의 API를 호출하여 인앱결제를 처리합니다.
	private PurchaseClientImpl _purchaseClient;

	//// 인앱 결제 초기화 (구글 & 원스토어 서비스에 연결하기 위한 선행 작업)
	public void Init()
	{
		// 구글일 때
		if (DataManager.instance.settingData.storeNum == 0)
		{
			if (IsInitialized())
			{
				return;
			}

			var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());

			for (int i = 0; i < productIds.Length; i++)
			{
				builder.AddProduct(productIds[i], UnityEngine.Purchasing.ProductType.Consumable, new IDs()
				{
					{ productIds[i], GooglePlay.Name }
				});
			}

			UnityPurchasing.Initialize(this, builder);
		}
		// 원스토어일 때
		else
		{
			_purchaseClient = new PurchaseClientImpl(_licenseKey);
			_purchaseClient.Initialize(this);
		}
	}
	private bool IsInitialized()
	{
		return storeController != null && extensionProvider != null;
	}

	//// 특정 상품 구매 요청하기 (버튼 이벤트)
	public void OnClickPurchase(string productId)
	{
		// 구글일 때
		if (DataManager.instance.settingData.storeNum == 0)
		{
			if (IsInitialized())
			{
				// 상품 정의
				Product product = storeController.products.WithID(productId);

				// 상품이 존재하면서 구매 가능하면..
				if (product != null && product.availableToPurchase)
				{
					// 구매가 가능하면 진행한다.
					storeController.InitiatePurchase(product);
				}
				else
				{
					GameManager.instance.SystemTextViewer.PrintText(SystemType.Debug, 0, false,
						"PurchaseProductID: FAIL. 상품을 찾을 수 없거나 구매할 수 없습니다.");
				}
			}
			else
			{
				GameManager.instance.SystemTextViewer.PrintText(SystemType.Debug, 0, false,
					"PurchaseProductID FAIL. IAP가 초기화되지 않았습니다.");
			}
		}
		// 원스토어일 때
		else
		{
			// 결제 로직
			var purchaseFlowParams = new PurchaseFlowParams.Builder()
			.SetProductId(productId)                                                // mandatory
			.SetProductType(OneStore.Purchasing.ProductType.INAPP)                  // mandatory
			.SetDeveloperPayload(DataManager.instance.playerData.gamerId)           // optional (따로 메모할만한 정보 여기에 넣기)
			.Build();

			_purchaseClient.Purchase(purchaseFlowParams);
		}
	}

	//// [ 구글용 Impl ]

	// 초기화 성공시 호출됨
	public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
	{
		storeController = controller;
		extensionProvider = extensions;
	}
	// 초기화 실패시 호출됨
	public void OnInitializeFailed(InitializationFailureReason error)
	{
		Debug.LogError("Unity IAP 초기화 실패: " + error);
	}
	public void OnInitializeFailed(InitializationFailureReason error, string message)
	{
		Debug.LogError("Unity IAP 초기화 실패: " + error);
	}
	// 구매 실패시 호출됨
	public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
	{
		Debug.LogError("구매 실패: " + product.definition.id + ", 사유: " + failureReason);
	}

	// 구매 확정 처리 + 뒤끝 영수증 검증 처리
	public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
	{
		string receiptJson = args.purchasedProduct.receipt;
		BackendReturnObject validation = Backend.Receipt.IsValidateGooglePurchase(receiptJson, "추가로 저장하고자 하는 내용", false);

		// 영수증 검증에 성공한 경우
		if (validation.IsSuccess())
		{
			// 상품 구매에 따른 상품 지급
		}   
		// 영수증 검증에 실패한 경우
		else
		{
			GameManager.instance.SystemTextViewer.PrintText(SystemType.Debug, 0, false,
				string.Format("ProcessPurchase: FAIL. Unrecognized product: '{0}'", args.purchasedProduct.definition.id));
		}

		// 구매 확정 처리.
		return PurchaseProcessingResult.Complete;
	}

	//// [ 원스토어용 Impl ]

	// 구매 성공시 호출 됨
	public void OnPurchaseSucceeded(List<PurchaseData> purchases)
	{
		// 구매 리스트가 비어 있을 경우 수동으로 구매 내역을 조회
		if (purchases == null || purchases.Count == 0)
		{
			Debug.LogError("구매 성공 호출되었으나 purchases 리스트가 null이거나 비어 있습니다. 구매 내역을 수동으로 조회합니다.");

			// 구매 내역 수동 조회
			_purchaseClient.QueryPurchases(OneStore.Purchasing.ProductType.INAPP);
		}

		foreach (PurchaseData purchase in purchases)
		{
			if (purchase != null)
			{
				try
				{
					// 구매 소비 (Consume) 처리
					_purchaseClient.ConsumePurchase(purchase);
					Debug.Log("구매 소비 성공: " + purchase.ProductId);
				}
				catch (Exception e)
				{
					Debug.LogError("구매 소비 중 오류 발생: " + e.Message);
				}
			}
			else
			{
				Debug.LogError("purchase 객체가 null입니다.");
			}
		}
	}
	// 구매 실패시 호출 됨
	public void OnPurchaseFailed(IapResult iapResult)
	{
		GameManager.instance.SystemTextViewer.PrintText(SystemType.Debug, 0, false,
			"구매에 실패했습니다.");
	}

	// 구매 후 소비 (3일 이내 자동환불 방지) + 뒤끝 영수증 검증 처리
	public void OnConsumeSucceeded(PurchaseData purchase)
	{
		// 원스토어에 국내에서만 앱을 출시하였을 경우
		bool isGlobal = false;

		BackendReturnObject validation =
			Backend.Receipt.IsValidateOneStorePurchase
			(isGlobal, purchase.ProductId, purchase.PurchaseToken, "receiptDescription");

		if (validation.IsSuccess())
		{
			// 상품 구매에 따른 상품 지급
		}
		else
		{
			// 영수증 검증 실패
			GameManager.instance.SystemTextViewer.PrintText(SystemType.Debug, 0, false, "영수증 검증 실패.");
		}
	}
	public void OnConsumeFailed(IapResult iapResult)
	{
		GameManager.instance.SystemTextViewer.PrintText(SystemType.Debug, 0, false, "소비에 실패했습니다.");
	}

코드들입니다. IAP 코드들입니다.

좋아요 1

감사합니다, 관련 내용 확인하여 안내드릴 수 있도록 하겠습니다.

감사합니다! 감사합니다!

공유해주신 내용은 담당부서로 전달하였습니다.
다만, 코드만으로는 현재 정확한 확인이 불가하여 추가 정보를 요청드립니다.

아래 이미지와 같이 주문 상품의 기록상태가 나오도록 오류가 난 상품에 대한 스크린샷을 DM으로 공유해주실 수 있으실까요?

전달해 드렸습니다!

좋아요 1

결제와 관련하여 뒤끝 서버에 보면
영수증 검증 이후에 상품 지급과 관련된 API인 Update나 Insert가 확인되지 않습니다.

아마도 영수증 검증 성공 후, 아이템 지급을 진행하다가 어떤 오류로 인해(ex: Exception 발생 혹은 앱멈춤 등) ProcessPurchase 함수가 끝까지 실행되지 않아서 PurchaseProcessingResult.Complete 를 리턴하지 못한 것으로 예상됩니다.

이 경우, 해당 상품은 Pending된 상태가 되어 게임을 재실행하면 UnityIAP가 복구 처리 로직을 실행하고 ProcessPurchase가 자동으로 호출됩니다.
따라서 정상적으로 복구될 수 있도록 복구 로직을 구성해 주셔야 합니다.

복구 로직이 성공했을 때, Pending된 상품을 IStoreController에 있는 ConfirmPendingPurchase을 통해 구매 완료로 처리해 주면 상품 구매가 정상적으로 종료됩니다.
만약 구매 완료로 처리하지 않으면 해당 결제는 언급해주신 것과 같이 3일 뒤에 자동 환불 처리 됩니다.

기존 구매 로직에서 영수증 검증 완료 후, 아이템 지급 시에 오류가 발생하지는 않는지 확인 부탁드리며
오류 발생 시에 구매를 복구하는 로직을 구성해 주시면 감사하겠습니다.