|
| 1 | +// |
| 2 | +// BranchEvent+StoreKit2.swift |
| 3 | +// Branch-SDK |
| 4 | +// |
| 5 | +// Created by Nidhi Dixit on 09/30/25. |
| 6 | +// Copyright 2024 Branch Metrics. All rights reserved. |
| 7 | +// |
| 8 | + |
| 9 | +import Foundation |
| 10 | +import StoreKit |
| 11 | +import BranchSDK |
| 12 | + |
| 13 | +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, *) |
| 14 | +extension BranchEvent { |
| 15 | + |
| 16 | + /// This method extracts detailed product and transaction information from a StoreKit 2 transaction |
| 17 | + /// and logs a Branch PURCHASE event with all the extracted information. |
| 18 | + /// - Parameter transaction: The StoreKit 2 transaction |
| 19 | + public func logEventForTransaction( transaction: Transaction) { |
| 20 | + Task { |
| 21 | + await logEventAsync(with: transaction) |
| 22 | + } |
| 23 | + } |
| 24 | + |
| 25 | + private func logEventAsync(with transaction: Transaction) async { |
| 26 | + do { |
| 27 | + let products = try await Product.products(for: [transaction.productID]) |
| 28 | + guard let product = products.first else { |
| 29 | + BranchLogger.shared().logError("Could not load product for transaction: \(transaction.productID)", error: nil) |
| 30 | + return |
| 31 | + } |
| 32 | + self.populateBUO(with: transaction, product: product) |
| 33 | + try await self.logEvent() |
| 34 | + BranchLogger.shared().logDebug("Created and logged StoreKit 2 event: \(self.description)", error: nil) |
| 35 | + } catch { |
| 36 | + BranchLogger.shared().logError("Failed to load product for StoreKit 2 transaction: \(error.localizedDescription)", error: error) |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + private func populateBUO(with transaction: Transaction, product: Product) { |
| 41 | + let buo = BranchUniversalObject() |
| 42 | + buo.canonicalIdentifier = product.id |
| 43 | + buo.title = product.displayName |
| 44 | + buo.contentDescription = product.description |
| 45 | + buo.contentMetadata.quantity = Double(transaction.purchasedQuantity) |
| 46 | + buo.contentMetadata.price = NSDecimalNumber(decimal: product.price) |
| 47 | + buo.contentMetadata.currency = BNCCurrency(rawValue: product.priceFormatStyle.currencyCode) |
| 48 | + buo.contentMetadata.productName = product.displayName |
| 49 | + |
| 50 | + var customMetadata: [String: Any] = [ |
| 51 | + "logged_from_storekit2": true, |
| 52 | + "product_type": product.type.rawValue, |
| 53 | + "transaction_id": String(transaction.id), |
| 54 | + "original_transaction_id": String(transaction.originalID), |
| 55 | + "purchase_date": ISO8601DateFormatter().string(from: transaction.purchaseDate), |
| 56 | + "purchased_quantity": transaction.purchasedQuantity |
| 57 | + ] |
| 58 | + |
| 59 | + if let subscriptionInfo = product.subscription { |
| 60 | + customMetadata["subscription_group_id"] = subscriptionInfo.subscriptionGroupID |
| 61 | + customMetadata["subscription_period"] = formatSubscriptionPeriod(subscriptionInfo.subscriptionPeriod) |
| 62 | + |
| 63 | + if let introductoryOffer = subscriptionInfo.introductoryOffer { |
| 64 | + customMetadata["introductory_offer_type"] = introductoryOffer.type.rawValue |
| 65 | + customMetadata["introductory_offer_period"] = formatSubscriptionPeriod(introductoryOffer.period) |
| 66 | + } |
| 67 | + } |
| 68 | + customMetadata["ownership_type"] = transaction.ownershipType.rawValue |
| 69 | + |
| 70 | + if let revocationDate = transaction.revocationDate { |
| 71 | + customMetadata["revocation_date"] = ISO8601DateFormatter().string(from: revocationDate) |
| 72 | + } |
| 73 | + if let revocationReason = transaction.revocationReason { |
| 74 | + customMetadata["revocation_reason"] = revocationReason.rawValue |
| 75 | + } |
| 76 | + |
| 77 | + buo.contentMetadata.customMetadata = NSMutableDictionary(dictionary: customMetadata) |
| 78 | + |
| 79 | + self.contentItems = [buo] |
| 80 | + self.eventName = "PURCHASE" |
| 81 | + self.transactionID = String(transaction.id) |
| 82 | + self.eventDescription = "StoreKit 2: \(product.displayName)" |
| 83 | + self.currency = BNCCurrency(rawValue: product.priceFormatStyle.currencyCode) |
| 84 | + self.revenue = NSDecimalNumber(decimal: product.price) |
| 85 | + |
| 86 | + switch product.type { |
| 87 | + case .autoRenewable, .nonRenewable: |
| 88 | + self.alias = "Subscription" |
| 89 | + case .consumable, .nonConsumable: |
| 90 | + self.alias = "IAP" |
| 91 | + default: |
| 92 | + self.alias = "IAP" |
| 93 | + } |
| 94 | + |
| 95 | + var eventCustomData: [String: String] = [:] |
| 96 | + eventCustomData["transaction_identifier"] = String(transaction.id) |
| 97 | + eventCustomData["logged_from_storekit2"] = "true" |
| 98 | + self.customData = eventCustomData |
| 99 | + } |
| 100 | + |
| 101 | + private func formatSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String { |
| 102 | + let unitString: String |
| 103 | + switch period.unit { |
| 104 | + case .day: |
| 105 | + unitString = "day" |
| 106 | + case .week: |
| 107 | + unitString = "week" |
| 108 | + case .month: |
| 109 | + unitString = "month" |
| 110 | + case .year: |
| 111 | + unitString = "year" |
| 112 | + @unknown default: |
| 113 | + unitString = "unknown" |
| 114 | + } |
| 115 | + return "\(period.value) \(unitString)\(period.value > 1 ? "s" : "")" |
| 116 | + } |
| 117 | +} |
0 commit comments