[FEAT] 크레딧 충천 페이지 API 연결#299
Conversation
— PortOne 노출 차단
— StoreKit 2 결제/조회/종료
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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.
| JSArray arr = new JSArray(); | ||
| for (ProductDetails pd : result.getProductDetailsList()) { | ||
| arr.put(toProductJS(pd)); | ||
| } |
There was a problem hiding this comment.
result는 List<ProductDetails> 타입이므로 getProductDetailsList() 메서드를 호출할 수 없어 컴파일 에러가 발생합니다. result가 null인지 안전하게 검사한 후 직접 순회하도록 수정해야 합니다.
| 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)); | |
| } | |
| } |
There was a problem hiding this comment.
billing:8.0.0 API는 다름.
| 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(); |
There was a problem hiding this comment.
result는 List<ProductDetails> 타입이므로 getProductDetailsList() 메서드를 호출할 수 없어 컴파일 에러가 발생합니다. 또한 result가 null이거나 비어있는 경우를 안전하게 처리하기 위해 방어 코드를 추가하는 것이 좋습니다.
| 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(); |
| 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]) |
There was a problem hiding this comment.
현재 구현에서는 .verified 상태인 트랜잭션만 처리하고 있습니다. 하지만 서버가 최종 검증 및 충전을 담당하므로, 클라이언트 측 로컬 검증이 실패하여 .unverified 상태가 되더라도 서버로 보내 검증할 수 있도록 해야 합니다.
그렇지 않으면 로컬 검증 실패 시(샌드박스 환경이나 기기 시간 오류 등) 해당 트랜잭션이 영구적으로 누락되어 사용자는 결제는 완료되었으나 크레딧을 받지 못하고, 재결제도 불가능한 상태(stuck)에 빠질 수 있습니다. unwrap 메서드를 사용해 트랜잭션을 가져오도록 수정하는 것을 권장합니다.
for await verification in Transaction.unfinished {
let transaction = self.unwrap(verification)
if transaction.revocationDate == nil {
purchases.append(self.purchaseDict(transaction))
}
}| 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 | ||
| } |
There was a problem hiding this comment.
getOwnedPurchases와 마찬가지로, 클라이언트 측 로컬 검증 실패로 인해 .unverified 상태인 미완료 트랜잭션이 존재할 때 이를 감지하지 못하고 새로운 결제를 시도하면 앱스토어에서 결제 오류가 발생하거나 트랜잭션이 꼬일 수 있습니다. unwrap을 통해 검증 여부와 상관없이 미완료 트랜잭션을 찾아 반환하도록 수정하는 것이 안전합니다.
| 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 | |
| } |
| import { useState } from "react"; | ||
| import { FindersBilling } from "@/lib/billing/finders-billing"; | ||
| import { useVerifyGooglePayment } from "@/hooks/payment/useVerifyGooglePayment"; | ||
| import type { CreditProduct } from "@/types/credit"; |
There was a problem hiding this comment.
구글 결제 플로우에서 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";| export function usePurchaseCredit() { | ||
| const verify = useVerifyGooglePayment(); | ||
| const [isPurchasing, setIsPurchasing] = useState(false); |
There was a problem hiding this comment.
캐시 무효화를 위해 useQueryClient 훅을 호출하여 queryClient 인스턴스를 확보해야 합니다.
| 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); |
| 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 { |
There was a problem hiding this comment.
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) };
}| JSArray arr = new JSArray(); | ||
| for (Purchase purchase : purchases) { | ||
| if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { | ||
| arr.put(toPurchaseJS(purchase)); | ||
| } | ||
| } |
There was a problem hiding this comment.
purchases 리스트가 null일 경우 NullPointerException이 발생할 수 있습니다. 루프를 돌기 전에 null 체크를 추가하여 안전하게 처리하는 것을 권장합니다.
| 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)); | |
| } | |
| } | |
| } |
| if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { | ||
| for (Purchase purchase : purchases) { | ||
| if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { | ||
| call.resolve(toPurchaseJS(purchase)); | ||
| return; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
purchases 리스트가 null일 경우 NullPointerException이 발생할 수 있습니다. 조건문에 purchases != null 체크를 추가하여 안전하게 처리하는 것을 권장합니다.
| 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; | |
| } | |
| } | |
| } |
🔀 Pull Request Title
크레딧 충천 페이지 API 연결
📌 PR 설명
크레딧 충천 페이지 API 연결
📷 스크린샷