Skip to content

[FEAT] 크레딧 충천 페이지 API 연결#299

Open
MlNTYS wants to merge 43 commits into
developfrom
feat/credit-page-API(#287)
Open

[FEAT] 크레딧 충천 페이지 API 연결#299
MlNTYS wants to merge 43 commits into
developfrom
feat/credit-page-API(#287)

Conversation

@MlNTYS

@MlNTYS MlNTYS commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

🔀 Pull Request Title

크레딧 충천 페이지 API 연결

📌 PR 설명

크레딧 충천 페이지 API 연결

  • 크레딧 조회
  • 크레딧 내역 조회
  • Android 구글 결제 연동
  • iOS Apple 결제 연동
  • PG 연동
  • 크레딧 구매

📷 스크린샷

image

MlNTYS added 30 commits June 7, 2026 12:37
@MlNTYS MlNTYS self-assigned this Jun 27, 2026
@MlNTYS MlNTYS added the feature 새 기능 추가 label Jun 27, 2026
@MlNTYS MlNTYS linked an issue Jun 27, 2026 that may be closed by this pull request
6 tasks
@vercel

vercel Bot commented Jun 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
finders Ready Ready Preview, Comment Jun 27, 2026 6:50am

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request integrates native in-app purchases for Android (Google Play Billing v8) and iOS (StoreKit 2) via custom Capacitor plugins, alongside PortOne web payments, replacing previous mock data with real API integrations for credit purchases and history. Feedback on the changes highlights critical compilation errors in the Android billing plugin regarding ProductDetails list handling, as well as potential null pointer exceptions. On iOS, the reviewer recommends handling unverified transactions to prevent purchases from getting stuck. Additionally, the React hook usePurchaseCredit.ts requires missing imports, a useQueryClient initialization, and proper handling of already-processed payments to align with the iOS implementation and prevent false failure screens.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +87 to +90
JSArray arr = new JSArray();
for (ProductDetails pd : result.getProductDetailsList()) {
arr.put(toProductJS(pd));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

resultList<ProductDetails> 타입이므로 getProductDetailsList() 메서드를 호출할 수 없어 컴파일 에러가 발생합니다. resultnull인지 안전하게 검사한 후 직접 순회하도록 수정해야 합니다.

Suggested change
JSArray arr = new JSArray();
for (ProductDetails pd : result.getProductDetailsList()) {
arr.put(toProductJS(pd));
}
JSArray arr = new JSArray();
if (result != null) {
for (ProductDetails pd : result) {
arr.put(toProductJS(pd));
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

billing:8.0.0 API는 다름.

Comment on lines +119 to +131
List<ProductDetails> list = result.getProductDetailsList();
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK
|| list.isEmpty()) {
call.reject("PRODUCT_NOT_FOUND: " + productId);
return;
}

BillingFlowParams flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(Collections.singletonList(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(list.get(0))
.build()))
.build();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

resultList<ProductDetails> 타입이므로 getProductDetailsList() 메서드를 호출할 수 없어 컴파일 에러가 발생합니다. 또한 resultnull이거나 비어있는 경우를 안전하게 처리하기 위해 방어 코드를 추가하는 것이 좋습니다.

Suggested change
List<ProductDetails> list = result.getProductDetailsList();
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK
|| list.isEmpty()) {
call.reject("PRODUCT_NOT_FOUND: " + productId);
return;
}
BillingFlowParams flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(Collections.singletonList(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(list.get(0))
.build()))
.build();
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK
|| result == null
|| result.isEmpty()) {
call.reject("PRODUCT_NOT_FOUND: " + productId);
return;
}
BillingFlowParams flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(Collections.singletonList(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(result.get(0))
.build()))
.build();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위와 동일.

Comment on lines +105 to +112
var purchases: [[String: Any]] = []
for await verification in Transaction.unfinished {
if case .verified(let transaction) = verification,
transaction.revocationDate == nil {
purchases.append(self.purchaseDict(transaction))
}
}
call.resolve(["purchases": purchases])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 구현에서는 .verified 상태인 트랜잭션만 처리하고 있습니다. 하지만 서버가 최종 검증 및 충전을 담당하므로, 클라이언트 측 로컬 검증이 실패하여 .unverified 상태가 되더라도 서버로 보내 검증할 수 있도록 해야 합니다.

그렇지 않으면 로컬 검증 실패 시(샌드박스 환경이나 기기 시간 오류 등) 해당 트랜잭션이 영구적으로 누락되어 사용자는 결제는 완료되었으나 크레딧을 받지 못하고, 재결제도 불가능한 상태(stuck)에 빠질 수 있습니다. unwrap 메서드를 사용해 트랜잭션을 가져오도록 수정하는 것을 권장합니다.

            for await verification in Transaction.unfinished {
                let transaction = self.unwrap(verification)
                if transaction.revocationDate == nil {
                    purchases.append(self.purchaseDict(transaction))
                }
            }

Comment on lines +144 to +153
private func firstUnfinishedTransaction(productId: String) async -> Transaction? {
for await verification in Transaction.unfinished {
if case .verified(let transaction) = verification,
transaction.productID == productId,
transaction.revocationDate == nil {
return transaction
}
}
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

getOwnedPurchases와 마찬가지로, 클라이언트 측 로컬 검증 실패로 인해 .unverified 상태인 미완료 트랜잭션이 존재할 때 이를 감지하지 못하고 새로운 결제를 시도하면 앱스토어에서 결제 오류가 발생하거나 트랜잭션이 꼬일 수 있습니다. unwrap을 통해 검증 여부와 상관없이 미완료 트랜잭션을 찾아 반환하도록 수정하는 것이 안전합니다.

Suggested change
private func firstUnfinishedTransaction(productId: String) async -> Transaction? {
for await verification in Transaction.unfinished {
if case .verified(let transaction) = verification,
transaction.productID == productId,
transaction.revocationDate == nil {
return transaction
}
}
return nil
}
private func firstUnfinishedTransaction(productId: String) async -> Transaction? {
for await verification in Transaction.unfinished {
let transaction = self.unwrap(verification)
if transaction.productID == productId,
transaction.revocationDate == nil {
return transaction
}
}
return nil
}

Comment on lines +1 to +4
import { useState } from "react";
import { FindersBilling } from "@/lib/billing/finders-billing";
import { useVerifyGooglePayment } from "@/hooks/payment/useVerifyGooglePayment";
import type { CreditProduct } from "@/types/credit";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

구글 결제 플로우에서 PAYMENT_ALREADY_PROCESSED_CODE 에러 및 캐시 무효화를 처리하기 위해 필요한 모듈들을 추가로 임포트해야 합니다.

import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { FindersBilling } from "@/lib/billing/finders-billing";
import { PAYMENT_ALREADY_PROCESSED_CODE } from "@/constants/payment/payment.constant";
import { invalidateCreditQueries } from "@/hooks/credit";
import { useVerifyGooglePayment } from "@/hooks/payment/useVerifyGooglePayment";
import { extractPortoneErrorCode } from "./usePurchaseCreditPortone";
import type { CreditProduct } from "@/types/credit";

Comment on lines +13 to +15
export function usePurchaseCredit() {
const verify = useVerifyGooglePayment();
const [isPurchasing, setIsPurchasing] = useState(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

캐시 무효화를 위해 useQueryClient 훅을 호출하여 queryClient 인스턴스를 확보해야 합니다.

Suggested change
export function usePurchaseCredit() {
const verify = useVerifyGooglePayment();
const [isPurchasing, setIsPurchasing] = useState(false);
export function usePurchaseCredit() {
const queryClient = useQueryClient();
const verify = useVerifyGooglePayment();
const [isPurchasing, setIsPurchasing] = useState(false);

Comment thread src/hooks/payment/usePurchaseCredit.ts Outdated
Comment on lines +28 to +44
await verify.mutateAsync({
// 검증엔 이미 검증된 SKU 사용 (브릿지 purchase.productId 의존 제거)
productId: sku,
purchaseToken: purchase.purchaseToken,
orderId: purchase.orderId ?? undefined,
});
return { status: "success" };
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message === "USER_CANCELED") {
return { status: "canceled" };
}
if (message === "PENDING") {
return { status: "pending" };
}
return { status: "fail", errorCode: message || undefined };
} finally {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

iOS(usePurchaseCreditApple.ts)와 달리 Android 구글 결제 플로우에서는 PAYMENT_ALREADY_PROCESSED_CODE (이미 처리된 결제) 에러를 예외 처리하지 않고 있습니다.

네트워크 지연이나 웹훅(RTDN)이 클라이언트보다 먼저 결제 검증 및 충전을 완료한 경우, 서버는 PAYMENT_ALREADY_PROCESSED_CODE를 반환하게 됩니다. 이때 클라이언트가 이를 실패로 처리하면 사용자는 실제로 크레딧 충전에 성공했음에도 화면에는 "결제 실패" 안내를 보게 되어 혼란을 줄 수 있습니다. iOS와 동일하게 해당 에러 코드를 성공으로 간주하고 캐시를 갱신하도록 수정하는 것을 권장합니다.

      try {
        await verify.mutateAsync({
          productId: sku,
          purchaseToken: purchase.purchaseToken,
          orderId: purchase.orderId ?? undefined,
        });
      } catch (error) {
        if (extractPortoneErrorCode(error) !== PAYMENT_ALREADY_PROCESSED_CODE) {
          throw error;
        }
        invalidateCreditQueries(queryClient);
      }
      return { status: "success" };
    } catch (error) {
      const message = error instanceof Error ? error.message : "";
      if (message === "USER_CANCELED") {
        return { status: "canceled" };
      }
      if (message === "PENDING") {
        return { status: "pending" };
      }
      return { status: "fail", errorCode: extractPortoneErrorCode(error) };
    }

Comment on lines +167 to +172
JSArray arr = new JSArray();
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
arr.put(toPurchaseJS(purchase));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

purchases 리스트가 null일 경우 NullPointerException이 발생할 수 있습니다. 루프를 돌기 전에 null 체크를 추가하여 안전하게 처리하는 것을 권장합니다.

Suggested change
JSArray arr = new JSArray();
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
arr.put(toPurchaseJS(purchase));
}
}
JSArray arr = new JSArray();
if (purchases != null) {
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
arr.put(toPurchaseJS(purchase));
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 부분에서 확인함

Comment on lines +215 to +222
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
call.resolve(toPurchaseJS(purchase));
return;
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

purchases 리스트가 null일 경우 NullPointerException이 발생할 수 있습니다. 조건문에 purchases != null 체크를 추가하여 안전하게 처리하는 것을 권장합니다.

Suggested change
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
call.resolve(toPurchaseJS(purchase));
return;
}
}
}
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) {
for (Purchase purchase : purchases) {
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
call.resolve(toPurchaseJS(purchase));
return;
}
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 새 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] 크레딧 충천 페이지 API 연결

1 participant