https://github.com/developeracademy-postech/2024-nc2-m12-in-app-purchase
애플 디벨로퍼 아카데미 3기 오전 12팀(한톨&나다) 멋진 인앱 결제 NC2 프로젝트
https://github.com/developeracademy-postech/2024-nc2-m12-in-app-purchase
Last synced: 4 days ago
JSON representation
애플 디벨로퍼 아카데미 3기 오전 12팀(한톨&나다) 멋진 인앱 결제 NC2 프로젝트
- Host: GitHub
- URL: https://github.com/developeracademy-postech/2024-nc2-m12-in-app-purchase
- Owner: DeveloperAcademy-POSTECH
- License: mit
- Created: 2024-06-14T01:23:04.000Z (8 months ago)
- Default Branch: main
- Last Pushed: 2024-06-20T13:32:48.000Z (8 months ago)
- Last Synced: 2024-12-26T07:42:04.637Z (about 2 months ago)
- Language: Swift
- Size: 543 KB
- Stars: 2
- Watchers: 2
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# 2024-NC2-M12-In-App-Purchase
애플 디벨로퍼 아카데미 3기 오전 12팀(한톨&나다) 멋진 인앱 결제 NC2 프로젝트
## Hi-fi

## Structure

## StoreKit Key Concept
### Product```swift
struct Product
```
- App Store의 제품을 표현하는 타입.
- displayName: String
- displayPrice: String
#### Product.currentEntitlement
- 사용자가 Product에 대한 자격이 없는 경우 `nil`.
- 비소모성 구독, 갱신되지 않는 구독 및 자동 갱신 구독에만 적용.
#### Product.PurchaseResult
- 구매 결과 타입
- 성공하면 `VerificationResult` 를 포함.
---
### VerificationResult
```swift
@frozen
enum VerificationResult
```
- StoreKit 검증(verification) 결과를 설명하는 타입.
- `Transacion`, `Product.SubscriptionInfo.RenewalInfo` `AppTransaction` 값을 자동으로 확인.
- case `verified`
- StoreKit 자동 확인 검사 통과
- case `unverified`
- StoreKit 자동 확인에 실패
---
### Transaction```swift
struct Transaction
```
- 고객이 앱에서 제품을 구매했음을 나타내는 정보. (거래)
- 고객이 인앱 구매를 하거나 구독을 갱신할 때 마다 `Transaction`을 생성.
- 다음과 같은 거래 관련 작업을 수행하려면 `Transaction`타입을 사용하자!
- 콘텐츠와 서비스를 잠금해제 하려면 사용자의 거래 내역(transaction history), 최신 거래(latest transaction), 현재 자격(current entitlements)을 확보.
- transaction 프로퍼티에 접근
- 앱에서 구매한 콘텐츠나 서비스를 제공 후 거래 완료
- JWS 문자열 및 지원 값에 접근해 거래 정보 확인.
- 앱이 실행되는 동안 새 Transaction 수신(Listen)
- 앱 내에서 환불 요청 시작.
- **앱에서 Transaction 객체를 생성하지 않음!!**
-> 대신 StoreKit은 사용자가 처음으로 앱을 시작할 떄를 포함해 최신 Transaction을 자동으로 만듬!
- 어떻게 만듬?
1. static `all` 시퀀스에 접근해 언제든지 `Transaction`을 가져오거나 `Product`의 `latestTransaction` 속성에 접근해 제품에 대한 가장 최근 `Transaction` 반환.
2. 사용자가 다른 기기에서 `Transaction`을 완료하면 앱이 실행되는 동안 transaction listener를 통해 새 Transaction에 대한 알림을 받습니다.
3. `transaction`을 사용해 구독 그룹의 최신 Transaction에 액세스.
4. 인앱 구매 성공 후 `Product.PurchaseResult.success(_:)` 를 통해 Transaction 반환.
- Transaction 정보의 가장 중요한 용도는 사용자가 어떤 인앱 구매에 대해 유료 액세스 권한을 가지고 있는지 확인 해 앱에서 콘텐츠나 서비스를 잠금해제할 수 있도록 하는 것.
- `originalPurchaseDate`: Date -> 원래 거래의 구매 날짜.
- `purchaseDate`: Date -> App Store에서 구매 또는 복원된 제품, 구독 구매 또는 만료 후 갱신에 대해 사용자 계정에 비용을 청구한 날짜.
- revocationDate: 거래를 환불하거나 가족 공유에서 취소한 날짜.
---
### static var updates: Transaction.Transactions { get }
- 앱 외부나 다른 장치에서 발생하는 Transaction을 생성하거나 업데이트할 때 내보내는 비동기 시퀀스.
- 동일한 장치에서 인앱 구매 성공 시 `Product.PurchaseResult.success(_:)`를 통해 Transaction 반환.
- 앱이 시작하자마자 리스너의 Transaction을 반복하는 `Task` 만들 것!## Code
### 1. 주요 엔티티: SaleCoupon(판매용 쿠폰)
```swift
/// 판매 쿠폰
struct SaleCoupon: Identifiable {
let id: Int
let title: String
let price: Decimal
let displayPrice: String
let target: PersonTarget
let emoji: String
}
```### 2. 주요 엔티티: PurchaseCoupon(구매한 쿠폰)
```swift
/// 구매한 쿠폰
/// 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
}
}
```### 3. 결제 결과 반환 타입: IAPResult
```swift
/// 인앱 구매 성공 시 반환하는 구조체
struct IAPResult {
let transactionId: UInt64
let purchaseDate: Date
}
```### 4. 구매 과정에서 일어날 수 있는 에러: IAPError
```swift
/// 인앱 구매 과정에서 발생할 수 있는 Error 열거형
enum IAPError: Error {
case cannotFoundProduct
case unverified
}
```### 5. StoreKit - Service 객체 구현
```swift
import StoreKitfinal class StoreService {
/// Product ID
/// 상품 ID를 들고 있음.
/// 얘는 원래는 앱스토어에 실제 등록한 productID를 가정한 배열.
/// 최적의 상황은 요런 productID를 백엔드 서버에서 들고 있다가
/// 프론트에 JSON 형태로 던져주는게 최적의 상황.
private var productIDs = ["coupon0", "coupon1", "coupon2"]
/// 새로운 거래가 발생하는지 확인하고 실행하는 리스너
var transactionListener: Task?
/// 전체 상품 반환
@MainActor
func requestProducts async -> [SaleCoupon] {}
/// 구매하기
@MainActore
func purchase(id: Int) async throws -> Result Task
/// 거래 Handling
@MainAction
func handle(transactionVerification result: VerificationResult async -> Transaction? {}
/// 현재 사용자의 자격 처리
func fetchCurrentEntitlements() async -> [Transaction] {}
}
```### 6. StoreKit - requestProducts() 전체 상품 반환
```swift
@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 []
}
}
```### 7. StoreKit - purchase() 상품 구매
```swift
@MainActor
func purchase(id: Int) async throws -> Result {// 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)
}
}
```### 8. 거래 Listener - 외부 기기나 웹사이트에서 거래 발생 시 업데이트 하는 Listener
```swift
/// Transaction Listener
private func listenForTransaction() -> Task {
// 1. Listen 매커니즘은 실시간으로 작업을 수행해야 함.
// 하지만 앱은 별개로 UI 액션과 같은 다른 작업을 수행해야 하기 때문에 Task를 분리하는 것.
return Task(priority: .background) {
// 2. 지속적으로 새로운 Transaction을 모니터링하고 새로운 Transaction이 나타내면 받아옴.
for await verificationResult in Transaction.updates {
// 3. Transaction 결과에 따른 핸들링.
await self.handle(transactionVerification: verificationResult)
}
}
}
```### 9. Transacion 결과에 따른 handling
```swift
/// Transaction 결과에 따른 처리
@MainActor
@discardableResult
private func handle(transactionVerification result:
VerificationResult) async -> Transaction? {
switch result {
// 거래 성공!
// transaction은 product 객체를 가지고 있지 않기 때문에 요렇게 ID 값을 이용해 불러옴.
case .verified(let transaction):
// 거래 프로세스 종료
await transaction.finish()
return transaction
// 거래 실패.
default: return nil
}
}
```### 10. 현재 사용자의 자격 처리 및 Transacion 반환
```swift
/// 현재 사용자의 자격 처리
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
}
```### 11. 환불하기
```swift
RefundView()
.refundRequestSheet(
for: selectedTransactionID ?? 0,
isPresented: $isRefundSheetPresented
)
```