애플 디벨로퍼 아카데미 3기 오전 12팀(한톨&나다) 멋진 인앱 결제 NC2 프로젝트
struct Product
- App Store의 제품을 표현하는 타입.
- displayName: String
- displayPrice: String
- 사용자가 Product에 대한 자격이 없는 경우
nil
. - 비소모성 구독, 갱신되지 않는 구독 및 자동 갱신 구독에만 적용.
- 구매 결과 타입
- 성공하면
VerificationResult
를 포함.
- 성공하면
@frozen
enum VerificationResult<SignedType>
- StoreKit 검증(verification) 결과를 설명하는 타입.
Transacion
,Product.SubscriptionInfo.RenewalInfo
AppTransaction
값을 자동으로 확인.
- case
verified
- StoreKit 자동 확인 검사 통과
- case
unverified
- StoreKit 자동 확인에 실패
struct Transaction
- 고객이 앱에서 제품을 구매했음을 나타내는 정보. (거래)
- 고객이 인앱 구매를 하거나 구독을 갱신할 때 마다
Transaction
을 생성.
- 고객이 인앱 구매를 하거나 구독을 갱신할 때 마다
- 다음과 같은 거래 관련 작업을 수행하려면
Transaction
타입을 사용하자!- 콘텐츠와 서비스를 잠금해제 하려면 사용자의 거래 내역(transaction history), 최신 거래(latest transaction), 현재 자격(current entitlements)을 확보.
- transaction 프로퍼티에 접근
- 앱에서 구매한 콘텐츠나 서비스를 제공 후 거래 완료
- JWS 문자열 및 지원 값에 접근해 거래 정보 확인.
- 앱이 실행되는 동안 새 Transaction 수신(Listen)
- 앱 내에서 환불 요청 시작.
- 앱에서 Transaction 객체를 생성하지 않음!! -> 대신 StoreKit은 사용자가 처음으로 앱을 시작할 떄를 포함해 최신 Transaction을 자동으로 만듬!
- 어떻게 만듬?
- static
all
시퀀스에 접근해 언제든지Transaction
을 가져오거나Product
의latestTransaction
속성에 접근해 제품에 대한 가장 최근Transaction
반환. - 사용자가 다른 기기에서
Transaction
을 완료하면 앱이 실행되는 동안 transaction listener를 통해 새 Transaction에 대한 알림을 받습니다. transaction
을 사용해 구독 그룹의 최신 Transaction에 액세스.- 인앱 구매 성공 후
Product.PurchaseResult.success(_:)
를 통해 Transaction 반환.
- static
- Transaction 정보의 가장 중요한 용도는 사용자가 어떤 인앱 구매에 대해 유료 액세스 권한을 가지고 있는지 확인 해 앱에서 콘텐츠나 서비스를 잠금해제할 수 있도록 하는 것.
originalPurchaseDate
: Date -> 원래 거래의 구매 날짜.purchaseDate
: Date -> App Store에서 구매 또는 복원된 제품, 구독 구매 또는 만료 후 갱신에 대해 사용자 계정에 비용을 청구한 날짜.- revocationDate: 거래를 환불하거나 가족 공유에서 취소한 날짜.
- 앱 외부나 다른 장치에서 발생하는 Transaction을 생성하거나 업데이트할 때 내보내는 비동기 시퀀스.
- 동일한 장치에서 인앱 구매 성공 시
Product.PurchaseResult.success(_:)
를 통해 Transaction 반환. - 앱이 시작하자마자 리스너의 Transaction을 반복하는
Task
만들 것!
- 동일한 장치에서 인앱 구매 성공 시
/// 판매 쿠폰
struct SaleCoupon: Identifiable {
let id: Int
let title: String
let price: Decimal
let displayPrice: String
let target: PersonTarget
let emoji: String
}
/// 구매한 쿠폰
/// SwiftData 사용
/// 소모성 아이템은 구매한 시점 이후로부터는
/// 앱이나 서버에서 관리 필요.
@Model
final class PurchaseCoupon: Identifiable {
let couponId: Int
let transactionId: UInt64
let purchaseDate: Date
var usedDate: Date?
var isUsed: Bool
var isRefundPending: Bool
init(
couponId: Int,
transactionId: UInt64,
purchaseDate: Date = .now,
usedDate: Date? = nil,
isUsed: Bool = false,
isRefundPending: Bool = false
) {
self.couponId = couponId
self.transactionId = transactionId
self.purchaseDate = purchaseDate
self.usedDate = usedDate
self.isUsed = isUsed
self.isRefundPending = isRefundPending
}
}
/// 인앱 구매 성공 시 반환하는 구조체
struct IAPResult {
let transactionId: UInt64
let purchaseDate: Date
}
/// 인앱 구매 과정에서 발생할 수 있는 Error 열거형
enum IAPError: Error {
case cannotFoundProduct
case unverified
}
import StoreKit
final class StoreService {
/// Product ID
/// 상품 ID를 들고 있음.
/// 얘는 원래는 앱스토어에 실제 등록한 productID를 가정한 배열.
/// 최적의 상황은 요런 productID를 백엔드 서버에서 들고 있다가
/// 프론트에 JSON 형태로 던져주는게 최적의 상황.
private var productIDs = ["coupon0", "coupon1", "coupon2"]
/// 새로운 거래가 발생하는지 확인하고 실행하는 리스너
var transactionListener: Task<Void, Error>?
/// 전체 상품 반환
@MainActor
func requestProducts async -> [SaleCoupon] {}
/// 구매하기
@MainActore
func purchase(id: Int) async throws -> Result<IAPResult, IAPError) {}
/// 거래가 발생 확인 Listener
private func listenForTransaction() -> Task<Void, Error>
/// 거래 Handling
@MainAction
func handle(transactionVerification result: VerificationResult<Transaction> async -> Transaction? {}
/// 현재 사용자의 자격 처리
func fetchCurrentEntitlements() async -> [Transaction] {}
}
@MainActor
func requestProducts() async -> [SaleCoupon] {
do {
// 1. 전체 상품 조회
let saleProducts = try await Product.products(for: productIDs)
// 2. 상품 ID를 통해 SaleCoupon으로 Mapping
let coupons = saleProducts.map {
if let lastChar = $0.id.last,
let id = Int(String(lastChar)) {
return SaleCoupon(
id: id,
title: $0.displayName,
price: $0.price,
displayPrice: $0.displayPrice,
target: saleCoupons[id].target,
emoji: saleCoupons[id].emoji
)
}
return SaleCoupon(
id: 0,
title: "",
price: 0,
displayPrice: "0",
target: .all,
emoji: ""
)
}
// 3. ID를 기준으로 정렬
return coupons.sorted { $0.id < $1.id }
} catch {
print(error.localizedDescription)
return []
}
}
@MainActor
func purchase(id: Int) async throws -> Result<IAPResult, IAPError> {
// 1. ID를 기준으로 Product
guard let product = try await Product.products(for: ["coupon\(id)"]).first else {
return .failure(.cannotFoundProduct)
}
// 2. 상품 구매 요청
// 반환값으로 Product.PurchaseResult 타입 반환
let result = try await product.purchase()
// 3. 구매 결과에 따라 분기 처리
switch result {
case .success(.verified(let transaction)):
// 거래 종료
await transaction.finish()
return .success(
IAPResult(
transactionId: transaction.id,
purchaseDate: transaction.purchaseDate
)
)
default: return .failure(.unverified)
}
}
/// Transaction Listener
private func listenForTransaction() -> Task<Void, Error> {
// 1. Listen 매커니즘은 실시간으로 작업을 수행해야 함.
// 하지만 앱은 별개로 UI 액션과 같은 다른 작업을 수행해야 하기 때문에 Task를 분리하는 것.
return Task(priority: .background) {
// 2. 지속적으로 새로운 Transaction을 모니터링하고 새로운 Transaction이 나타내면 받아옴.
for await verificationResult in Transaction.updates {
// 3. Transaction 결과에 따른 핸들링.
await self.handle(transactionVerification: verificationResult)
}
}
}
/// Transaction 결과에 따른 처리
@MainActor
@discardableResult
private func handle(transactionVerification result:
VerificationResult<Transaction>) async -> Transaction? {
switch result {
// 거래 성공!
// transaction은 product 객체를 가지고 있지 않기 때문에 요렇게 ID 값을 이용해 불러옴.
case .verified(let transaction):
// 거래 프로세스 종료
await transaction.finish()
return transaction
// 거래 실패.
default: return nil
}
}
/// 현재 사용자의 자격 처리
func fetchCurrentEntitlements() async -> [Transaction] {
var transactions: [Transaction] = []
// currentEntitlements
// 인터넷에 연결되어 있는 경우 최신 Transaction을 검색
// 인터넷 연결이 없으면 로컬로 캐시된 데이터를 가져옴.
// + 인터넷 연결이 복원되면 거래가 자동으로 기기에 동기화.
for await result in Transaction.currentEntitlements {
guard let transaction = await self.handle(transactionVerification: result) else {
return []
}
transactions.append(transaction)
}
return transactions
}
RefundView()
.refundRequestSheet(
for: selectedTransactionID ?? 0,
isPresented: $isRefundSheetPresented
)