# การสัมภาษณ์ StoreKit 2: การจัดการการสมัครสมาชิกและการตรวจสอบใบเสร็จ > เชี่ยวชาญคำถามสัมภาษณ์ iOS เกี่ยวกับ StoreKit 2 การจัดการการสมัครสมาชิก การตรวจสอบใบเสร็จ และการนำการซื้อในแอปไปใช้ พร้อมตัวอย่างโค้ด Swift ที่ใช้งานได้จริง - Published: 2026-03-09 - Updated: 2026-04-29 - Author: SharpSkill - Tags: ios, storekit, in-app-purchase, swift, interview - Reading time: 14 min --- StoreKit 2 แสดงถึงการเปลี่ยนแปลงพื้นฐานในวิธีจัดการการซื้อในแอปบน iOS เปิดตัวพร้อมกับ iOS 15 framework ที่ทันสมัยนี้ช่วยลดความซับซ้อนของโค้ดที่จำเป็นสำหรับการจัดการการสมัครสมาชิกและการตรวจสอบธุรกรรมอย่างมาก การสัมภาษณ์ทางเทคนิค iOS มักครอบคลุมหัวข้อนี้เพื่อประเมินความเชี่ยวชาญของนักพัฒนาในการสร้างรายได้จากแอป > **ข้อมูลเชิงลึกสำคัญสำหรับการสัมภาษณ์** > > StoreKit 2 ใช้ประโยชน์จาก API Swift ดั้งเดิมด้วย async/await ทำให้ไม่จำเป็นต้องใช้ callback ที่ซับซ้อนและการตรวจสอบใบเสร็จฝั่งไคลเอนต์ ผู้สัมภาษณ์ให้ความสำคัญกับผู้สมัครที่สามารถอธิบายความแตกต่างพื้นฐานจาก StoreKit 1 ได้ ## ภาพรวมสถาปัตยกรรม StoreKit 2 StoreKit 2 ถูกสร้างขึ้นบนสถาปัตยกรรมที่ทันสมัยซึ่งใช้ประโยชน์จาก Swift Concurrency อย่างเต็มที่ ไม่เหมือน StoreKit 1 การดำเนินการทั้งหมดเป็นแบบอะซิงโครนัสและใช้ประเภท Swift ดั้งเดิม ### การดึงผลิตภัณฑ์ที่มีอยู่ ขั้นตอนแรกเกี่ยวข้องกับการโหลดผลิตภัณฑ์ที่กำหนดค่าใน App Store Connect StoreKit 2 ทำให้การดำเนินการนี้ง่ายขึ้นด้วย API แบบประกาศ ```swift // ProductService.swift import StoreKit actor ProductService { // ตัวระบุผลิตภัณฑ์ที่กำหนดค่าใน App Store Connect private let productIdentifiers: Set = [ "com.app.subscription.monthly", "com.app.subscription.yearly", "com.app.premium.lifetime" ] // แคชผลิตภัณฑ์เพื่อหลีกเลี่ยงการเรียกเครือข่ายซ้ำ private var cachedProducts: [Product] = [] func loadProducts() async throws -> [Product] { // ส่งคืนแคชหากมี guard cachedProducts.isEmpty else { return cachedProducts } // คำขออะซิงโครนัสไปยัง App Store let products = try await Product.products(for: productIdentifiers) cachedProducts = products return products } func product(for identifier: String) async throws -> Product? { let products = try await loadProducts() return products.first { $0.id == identifier } } } ``` การใช้ `actor` รับประกันความปลอดภัยของเธรดเมื่อจัดการแคชผลิตภัณฑ์ ### ประเภทผลิตภัณฑ์ใน StoreKit 2 StoreKit 2 แยกแยะประเภทผลิตภัณฑ์ที่แตกต่างกันอย่างชัดเจน การเข้าใจความแตกต่างนี้เป็นสิ่งสำคัญสำหรับการสัมภาษณ์ ```swift // ProductType+Extensions.swift import StoreKit extension Product.ProductType { var displayName: String { switch self { case .consumable: // ผลิตภัณฑ์สิ้นเปลือง (เครดิต ชีวิต ฯลฯ) return "Consumable" case .nonConsumable: // การซื้อถาวร (ปลดล็อกฟีเจอร์) return "Non-Consumable" case .autoRenewable: // การสมัครสมาชิกพร้อมการต่ออายุอัตโนมัติ return "Auto-Renewable Subscription" case .nonRenewable: // การสมัครสมาชิกไม่ต่ออายุ (บัตรชั่วคราว) return "Non-Renewable Subscription" @unknown default: return "Unknown" } } } ``` การสมัครสมาชิกแบบต่ออายุอัตโนมัติเป็นรูปแบบธุรกิจหลักสำหรับแอปพลิเคชัน iOS ที่ทันสมัย ## การจัดการธุรกรรมการซื้อ กระบวนการซื้อใน StoreKit 2 กลายเป็นเชิงเส้นด้วย async/await ไม่มี delegate และ callback ซ้อนกันจาก StoreKit 1 อีกต่อไป ### การเริ่มต้นการซื้อ ```swift // PurchaseManager.swift import StoreKit actor PurchaseManager { enum PurchaseError: Error { case productNotFound case purchaseCancelled case purchasePending case verificationFailed case unknown } func purchase(_ product: Product) async throws -> Transaction { // เริ่มการซื้อด้วย modal ของระบบ let result = try await product.purchase() switch result { case .success(let verification): // ตรวจสอบลายเซ็นของธุรกรรม let transaction = try checkVerification(verification) // ทำเครื่องหมายธุรกรรมว่าเสร็จสิ้น await transaction.finish() return transaction case .userCancelled: // ผู้ใช้ยกเลิก modal การซื้อ throw PurchaseError.purchaseCancelled case .pending: // การซื้อรอดำเนินการ (การอนุมัติของผู้ปกครอง ฯลฯ) throw PurchaseError.purchasePending @unknown default: throw PurchaseError.unknown } } private func checkVerification(_ result: VerificationResult) throws -> T { switch result { case .verified(let safe): // ธุรกรรมลงนามและตรวจสอบโดย App Store return safe case .unverified(_, let error): // ลายเซ็นไม่ถูกต้องหรือเสียหาย throw PurchaseError.verificationFailed } } } ``` เมธอด `finish()` มีความสำคัญอย่างยิ่ง: มันแจ้ง App Store ว่าเนื้อหาได้ถูกส่งมอบให้ผู้ใช้แล้ว > **คำเตือนการสัมภาษณ์** > > อย่าลืมเรียก `transaction.finish()` หลังจากส่งมอบเนื้อหา ธุรกรรมที่ยังไม่เสร็จสิ้นจะถูกกู้คืนในการเปิดแอปครั้งถัดไป ทำให้เกิดพฤติกรรมที่ไม่คาดคิด ### การฟังธุรกรรมเบื้องหลัง StoreKit 2 แนะนำ `Transaction.updates` ซึ่งเป็นสตรีมอะซิงโครนัสที่ปล่อยธุรกรรมที่เกิดขึ้นภายนอกแอป (การต่ออายุ การซื้อ family sharing ฯลฯ) ```swift // TransactionObserver.swift import StoreKit actor TransactionObserver { private var updateTask: Task? func startObserving() { // ยกเลิกการสังเกตการณ์ก่อนหน้า updateTask?.cancel() updateTask = Task(priority: .background) { // สตรีมอัปเดตธุรกรรมไม่จำกัด for await result in Transaction.updates { do { let transaction = try self.checkVerification(result) // ประมวลผลธุรกรรมตามประเภท await self.handleTransaction(transaction) // ทำธุรกรรมให้เสร็จสมบูรณ์เสมอ await transaction.finish() } catch { // บันทึกข้อผิดพลาดสำหรับการดีบัก print("Transaction verification failed: \(error)") } } } } func stopObserving() { updateTask?.cancel() updateTask = nil } private func handleTransaction(_ transaction: Transaction) async { switch transaction.productType { case .autoRenewable: // อัปเดตสถานะการสมัครสมาชิก await updateSubscriptionStatus(transaction) case .nonConsumable: // ปลดล็อกฟีเจอร์พรีเมียม await unlockFeature(transaction.productID) default: break } } private func checkVerification(_ result: VerificationResult) throws -> T { switch result { case .verified(let safe): return safe case .unverified: throw PurchaseError.verificationFailed } } private func updateSubscriptionStatus(_ transaction: Transaction) async { // การใช้งานการอัปเดตสถานะ } private func unlockFeature(_ productID: String) async { // การใช้งานการปลดล็อกฟีเจอร์ } enum PurchaseError: Error { case verificationFailed } } ``` การสังเกตการณ์ธุรกรรมควรเริ่มทันทีที่แอปเปิด เพื่อหลีกเลี่ยงการพลาดอัปเดตใดๆ ## คำถามสัมภาษณ์ทั่วไปเกี่ยวกับการสมัครสมาชิก StoreKit 2 การสัมภาษณ์ iOS มักรวมถึงคำถามเฉพาะเกี่ยวกับการจัดการการสมัครสมาชิก ต่อไปนี้เป็นคำถามที่พบบ่อยที่สุดพร้อมคำตอบโดยละเอียด ### คำถามที่ 1: จะตรวจสอบสถานะการสมัครสมาชิกปัจจุบันได้อย่างไร? StoreKit 2 ให้การเข้าถึง entitlement ที่ใช้งานอยู่โดยตรงผ่าน `Transaction.currentEntitlements` ```swift // SubscriptionStatusChecker.swift import StoreKit struct SubscriptionStatusChecker { struct SubscriptionStatus { let isActive: Bool let expirationDate: Date? let willRenew: Bool let productID: String? } static func checkCurrentStatus() async -> SubscriptionStatus { // ดึงธุรกรรมที่ใช้งานอยู่ทั้งหมด var latestTransaction: Transaction? for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { // กรองเฉพาะการสมัครสมาชิก if transaction.productType == .autoRenewable { // เก็บธุรกรรมล่าสุด if latestTransaction == nil || transaction.purchaseDate > latestTransaction!.purchaseDate { latestTransaction = transaction } } } } guard let transaction = latestTransaction else { return SubscriptionStatus( isActive: false, expirationDate: nil, willRenew: false, productID: nil ) } // ตรวจสอบว่าการสมัครสมาชิกยังใช้งานได้หรือไม่ let isActive = transaction.expirationDate ?? Date.distantPast > Date() // ดึงข้อมูลการต่ออายุ let renewalInfo = await getRenewalInfo(for: transaction.productID) return SubscriptionStatus( isActive: isActive, expirationDate: transaction.expirationDate, willRenew: renewalInfo?.willAutoRenew ?? false, productID: transaction.productID ) } private static func getRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? { guard let product = try? await Product.products(for: [productID]).first, let subscription = product.subscription else { return nil } // ดึงสถานะการต่ออายุ let status = try? await subscription.status.first return status?.renewalInfo } } ``` วิธีการนี้ให้สถานะการสมัครสมาชิกที่ถูกต้องโดยไม่ต้องเรียกเซิร์ฟเวอร์ ### คำถามที่ 2: จะจัดการการทดลองใช้ฟรีและข้อเสนอโปรโมชันได้อย่างไร? StoreKit 2 ทำให้การเข้าถึงข้อมูลข้อเสนอง่ายขึ้น ผู้สัมภาษณ์มักทดสอบความเข้าใจเกี่ยวกับประเภทข้อเสนอที่แตกต่างกัน ```swift // OfferManager.swift import StoreKit struct OfferManager { enum OfferType { case freeTrial(duration: String) case payAsYouGo(periods: Int, price: Decimal) case payUpFront(duration: String, price: Decimal) case none } static func getIntroductoryOffer(for product: Product) -> OfferType { guard let subscription = product.subscription, let offer = subscription.introductoryOffer else { return .none } switch offer.paymentMode { case .freeTrial: // ระยะเวลาทดลองใช้ฟรี return .freeTrial(duration: formatPeriod(offer.period)) case .payAsYouGo: // ราคาที่ลดลงตลอดหลายระยะเวลา return .payAsYouGo( periods: offer.periodCount, price: offer.price ) case .payUpFront: // การชำระเงินครั้งเดียวสำหรับระยะเวลา return .payUpFront( duration: formatPeriod(offer.period), price: offer.price ) @unknown default: return .none } } static func checkEligibility(for product: Product) async -> Bool { // ตรวจสอบว่าผู้ใช้สามารถได้รับประโยชน์จากข้อเสนอหรือไม่ guard let subscription = product.subscription else { return false } // isEligibleForIntroOffer ระบุว่าเป็นสมาชิกใหม่หรือไม่ return await subscription.isEligibleForIntroOffer } private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String { let value = period.value switch period.unit { case .day: return "\(value) day\(value > 1 ? "s" : "")" case .week: return "\(value) week\(value > 1 ? "s" : "")" case .month: return "\(value) month\(value > 1 ? "s" : "")" case .year: return "\(value) year\(value > 1 ? "s" : "")" @unknown default: return "" } } } ``` เมธอด `isEligibleForIntroOffer` มีความสำคัญในการหลีกเลี่ยงการแสดงข้อเสนอที่ไม่สามารถใช้งานได้ ### คำถามที่ 3: จะกู้คืนการซื้อก่อนหน้าได้อย่างไร? การกู้คืนการซื้อเป็นฟีเจอร์บังคับตามแนวทางของ App Store ```swift // RestoreManager.swift import StoreKit actor RestoreManager { struct RestoreResult { let restoredProducts: [String] let activeSubscriptions: [String] } func restorePurchases() async throws -> RestoreResult { var restoredProducts: [String] = [] var activeSubscriptions: [String] = [] // ซิงโครไนซ์กับ App Store try await AppStore.sync() // วนซ้ำธุรกรรมทั้งหมดของผู้ใช้ for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } switch transaction.productType { case .nonConsumable: // กู้คืนการซื้อถาวร restoredProducts.append(transaction.productID) case .autoRenewable: // ตรวจสอบว่าการสมัครสมาชิกใช้งานได้หรือไม่ if let expiration = transaction.expirationDate, expiration > Date() { activeSubscriptions.append(transaction.productID) } default: // สินค้าสิ้นเปลืองไม่สามารถกู้คืนได้ break } } return RestoreResult( restoredProducts: restoredProducts, activeSubscriptions: activeSubscriptions ) } } ``` `AppStore.sync()` บังคับการซิงโครไนซ์กับเซิร์ฟเวอร์ของ Apple มีประโยชน์หลังจากการติดตั้งใหม่ ## การตรวจสอบใบเสร็จฝั่งเซิร์ฟเวอร์ การตรวจสอบฝั่งเซิร์ฟเวอร์ยังคงแนะนำสำหรับแอปพลิเคชันที่มีการสมัครสมาชิกที่สำคัญ StoreKit 2 แนะนำ JWS (JSON Web Signatures) สำหรับการตรวจสอบที่ทันสมัย ### การดึงข้อมูลธุรกรรมสำหรับเซิร์ฟเวอร์ ```swift // ServerValidation.swift import StoreKit struct TransactionPayload: Codable { let transactionID: String let originalTransactionID: String let productID: String let purchaseDate: Date let expirationDate: Date? let jwsRepresentation: String } actor ServerValidationService { private let apiEndpoint = "https://api.example.com/verify" func validateWithServer(_ transaction: Transaction) async throws -> Bool { // สร้าง payload สำหรับเซิร์ฟเวอร์ let payload = TransactionPayload( transactionID: String(transaction.id), originalTransactionID: String(transaction.originalID), productID: transaction.productID, purchaseDate: transaction.purchaseDate, expirationDate: transaction.expirationDate, jwsRepresentation: transaction.jwsRepresentation ?? "" ) // เข้ารหัสเป็น JSON let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let jsonData = try encoder.encode(payload) // ส่งไปยังเซิร์ฟเวอร์ var request = URLRequest(url: URL(string: apiEndpoint)!) request.httpMethod = "POST" request.httpBody = jsonData request.setValue("application/json", forHTTPHeaderField: "Content-Type") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return false } // ถอดรหัสการตอบสนองของเซิร์ฟเวอร์ let validationResponse = try JSONDecoder().decode( ValidationResponse.self, from: data ) return validationResponse.isValid } } struct ValidationResponse: Codable { let isValid: Bool let subscriptionStatus: String? } ``` `jwsRepresentation` มีลายเซ็นเข้ารหัสที่เซิร์ฟเวอร์สามารถตรวจสอบได้ > **App Store Server API** > > ขณะนี้ Apple ให้บริการ App Store Server API สำหรับการตรวจสอบฝั่งเซิร์ฟเวอร์ API ที่ทันสมัยนี้แทนที่ endpoint verifyReceipt ที่เลิกใช้และมีฟีเจอร์ขั้นสูงเช่นการแจ้งเตือนเซิร์ฟเวอร์ถึงเซิร์ฟเวอร์ ### การกำหนดค่าการแจ้งเตือนเซิร์ฟเวอร์ Server Notifications V2 ช่วยให้รับเหตุการณ์แบบเรียลไทม์ได้ ```swift // ServerNotificationTypes.swift enum NotificationType: String, Codable { case subscribed = "SUBSCRIBED" case didRenew = "DID_RENEW" case didFailToRenew = "DID_FAIL_TO_RENEW" case didChangeRenewalStatus = "DID_CHANGE_RENEWAL_STATUS" case expired = "EXPIRED" case gracePeriodExpired = "GRACE_PERIOD_EXPIRED" case offerRedeemed = "OFFER_REDEEMED" case priceIncrease = "PRICE_INCREASE" case refund = "REFUND" case renewalExtended = "RENEWAL_EXTENDED" case revoke = "REVOKE" } struct ServerNotification: Codable { let notificationType: NotificationType let subtype: String? let data: NotificationData struct NotificationData: Codable { let bundleId: String let environment: String let signedTransactionInfo: String let signedRenewalInfo: String? } } ``` การแจ้งเตือนเหล่านี้ช่วยให้รักษาสถานะการสมัครสมาชิกซิงโครไนซ์กับ backend ได้ ## การจัดการข้อผิดพลาดและกรณีขอบ การสัมภาษณ์มักทดสอบความเข้าใจเกี่ยวกับสถานการณ์ข้อผิดพลาดและการจัดการที่เหมาะสม ### ข้อผิดพลาดทั่วไปใน StoreKit 2 ```swift // PurchaseErrorHandler.swift import StoreKit struct PurchaseErrorHandler { enum UserFacingError { case networkError case paymentFailed case notAuthorized case productNotAvailable case unknown var message: String { switch self { case .networkError: return "Connection failed. Please check your internet connection." case .paymentFailed: return "Payment failed. Please verify your payment information." case .notAuthorized: return "In-app purchases are disabled on this device." case .productNotAvailable: return "This product is not currently available." case .unknown: return "An unexpected error occurred." } } } static func handle(_ error: Error) -> UserFacingError { // ตรวจสอบว่าเป็นข้อผิดพลาด StoreKit หรือไม่ if let storeKitError = error as? StoreKitError { switch storeKitError { case .networkError: return .networkError case .userCancelled: // ไม่แสดงข้อผิดพลาด ผู้ใช้ยกเลิก return .unknown case .notAvailableInStorefront: return .productNotAvailable case .notEntitled: return .notAuthorized default: return .unknown } } // ข้อผิดพลาดในการซื้อ if let purchaseError = error as? Product.PurchaseError { switch purchaseError { case .invalidQuantity: return .unknown case .productUnavailable: return .productNotAvailable case .purchaseNotAllowed: return .notAuthorized default: return .unknown } } return .unknown } } ``` การจัดการข้อผิดพลาดที่ชัดเจนช่วยปรับปรุงประสบการณ์ผู้ใช้และทำให้การดีบักง่ายขึ้น ### การสนับสนุนโหมดออฟไลน์ ```swift // OfflineCapabilityManager.swift import StoreKit actor OfflineCapabilityManager { private let userDefaults = UserDefaults.standard private let entitlementsKey = "cached_entitlements" func cacheCurrentEntitlements() async { var entitlements: [String: Date] = [:] for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { entitlements[transaction.productID] = transaction.expirationDate } } // บันทึกในเครื่อง if let data = try? JSONEncoder().encode(entitlements) { userDefaults.set(data, forKey: entitlementsKey) } } func getCachedEntitlements() -> [String: Date] { guard let data = userDefaults.data(forKey: entitlementsKey), let entitlements = try? JSONDecoder().decode( [String: Date].self, from: data ) else { return [:] } // กรอง entitlement ที่หมดอายุ let now = Date() return entitlements.filter { _, expiration in expiration > now } } func hasValidEntitlement(for productID: String) -> Bool { let cached = getCachedEntitlements() return cached[productID] != nil } } ``` การแคชในเครื่องช่วยรักษาการเข้าถึงฟีเจอร์พรีเมียมแม้ไม่มีการเชื่อมต่อ ## แนวทางปฏิบัติที่ดีที่สุดสำหรับการผลิต ### สถาปัตยกรรมที่แนะนำ ```swift // SubscriptionService.swift import StoreKit import Combine @MainActor final class SubscriptionService: ObservableObject { static let shared = SubscriptionService() @Published private(set) var products: [Product] = [] @Published private(set) var purchasedProductIDs: Set = [] @Published private(set) var subscriptionStatus: SubscriptionStatus = .none private var transactionObserver: Task? enum SubscriptionStatus { case none case active(expirationDate: Date) case expired case inGracePeriod } private init() { // เริ่มการสังเกตการณ์ธุรกรรม startTransactionObserver() // โหลดสถานะเริ่มต้น Task { await loadProducts() await updateSubscriptionStatus() } } private func startTransactionObserver() { transactionObserver = Task.detached(priority: .background) { for await result in Transaction.updates { if case .verified(let transaction) = result { await transaction.finish() await self.updateSubscriptionStatus() } } } } func loadProducts() async { do { let productIDs = [ "com.app.subscription.monthly", "com.app.subscription.yearly" ] products = try await Product.products(for: productIDs) } catch { print("Failed to load products: \(error)") } } func purchase(_ product: Product) async throws { let result = try await product.purchase() switch result { case .success(let verification): if case .verified(let transaction) = verification { await transaction.finish() await updateSubscriptionStatus() } case .userCancelled: break case .pending: break @unknown default: break } } func updateSubscriptionStatus() async { var purchasedIDs: Set = [] var latestSubscription: Transaction? for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { purchasedIDs.insert(transaction.productID) if transaction.productType == .autoRenewable { if latestSubscription == nil || transaction.purchaseDate > latestSubscription!.purchaseDate { latestSubscription = transaction } } } } purchasedProductIDs = purchasedIDs if let subscription = latestSubscription, let expiration = subscription.expirationDate { if expiration > Date() { subscriptionStatus = .active(expirationDate: expiration) } else { subscriptionStatus = .expired } } else { subscriptionStatus = .none } } deinit { transactionObserver?.cancel() } } ``` สถาปัตยกรรมแบบรวมศูนย์นี้ทำให้การจัดการสถานะการซื้อทั่วทั้งแอปพลิเคชันง่ายขึ้น ## สรุป StoreKit 2 เปลี่ยนแปลงการจัดการการซื้อในแอปให้เป็นประสบการณ์การพัฒนาที่ทันสมัยและปลอดภัย จุดสำคัญที่ควรจดจำสำหรับการสัมภาษณ์: ✅ API async/await ดั้งเดิม: ไม่มี delegate และ callback ที่ซับซ้อนจาก StoreKit 1 อีกต่อไป ✅ การตรวจสอบอัตโนมัติ: `VerificationResult` จัดการการตรวจสอบเข้ารหัสของธุรกรรม ✅ `Transaction.updates`: สตรีมอะซิงโครนัสสำหรับธุรกรรมเบื้องหลัง ✅ `Transaction.currentEntitlements`: การเข้าถึง entitlement ที่ใช้งานอยู่ของผู้ใช้โดยตรง ✅ ข้อเสนอโปรโมชัน: `isEligibleForIntroOffer` เพื่อตรวจสอบความเหมาะสม ✅ การตรวจสอบเซิร์ฟเวอร์: JWS และ App Store Server API เพื่อความปลอดภัยที่เพิ่มขึ้น ✅ `transaction.finish()`: บังคับหลังจากการส่งมอบเนื้อหา ✅ การจัดการออฟไลน์: การแคช entitlement ในเครื่องเพื่อประสบการณ์ที่ราบรื่น --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/ios/storekit-2-subscriptions-receipts-validation-interview