From 2199384f887cf5634f3fdcf651eeb9c2c7ed457a Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 19 Apr 2026 15:02:43 -0500 Subject: fix: finish half-completed storekit implementation --- Docs/StoreKit.md | 42 ++++++++ Hutch.xcodeproj/project.pbxproj | 18 ++-- .../xcshareddata/WorkspaceSettings.xcsettings | 5 + .../xcshareddata/xcschemes/Hutch.xcscheme | 3 + Hutch/Views/More/AboutView.swift | 71 +++++++++++-- Hutch/Views/More/TipStoreViewModel.swift | 118 ++++++++++++++++++--- HutchTests/TipStoreViewModelTests.swift | 22 ++++ SyncedProducts.storekit | 87 +++++++++++++++ 8 files changed, 338 insertions(+), 28 deletions(-) create mode 100644 Docs/StoreKit.md create mode 100644 Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 HutchTests/TipStoreViewModelTests.swift create mode 100644 SyncedProducts.storekit diff --git a/Docs/StoreKit.md b/Docs/StoreKit.md new file mode 100644 index 0000000..0d2eb33 --- /dev/null +++ b/Docs/StoreKit.md @@ -0,0 +1,42 @@ +# StoreKit Setup For Hutch + +Hutch uses StoreKit 2 for one-time consumable tip products: + +- `net.cleberg.hutch.tip.small` +- `net.cleberg.hutch.tip.medium` +- `net.cleberg.hutch.tip.large` + +## Xcode setup + +1. Open `Hutch.xcodeproj` in Xcode. +2. Select the `Hutch` target. +3. Open `Signing & Capabilities`. +4. Confirm the App ID for `net.cleberg.Hutch` has the In-App Purchase capability enabled in the Apple Developer portal. +5. In Xcode, choose `File > New > File... > StoreKit Configuration File`. +6. Prefer a synced StoreKit file so Xcode pulls the real products from App Store Connect. +7. Open `Product > Scheme > Edit Scheme...`. +8. Select `Run`, then the `Options` tab. +9. Set `StoreKit Configuration` to the synced `.storekit` file for local testing. + +## App Store Connect checks + +1. Confirm all three products exist with the exact product IDs listed above. +2. Confirm each product has complete metadata, pricing, and availability. +3. If a product is in `Developer Action Needed`, edit the rejected detail or cancel the pending change, then resubmit it. +4. Wait up to one hour for metadata changes to propagate to sandbox. + +## Sandbox test checklist + +1. On a physical device, enable Developer Mode if needed. +2. Sign in to a Sandbox Apple Account in `Settings > Developer > Sandbox Apple Account`. +3. Install a development build from Xcode or a TestFlight build. +4. Open `More > About`. +5. Verify the tip products load with names and prices. +6. Complete one purchase successfully. +7. Use `Restore / Sync Purchases` to verify StoreKit can talk to the App Store account. +8. Record this full flow for App Review when resubmitting. + +## Code locations + +- `Hutch/Views/More/TipStoreViewModel.swift` +- `Hutch/Views/More/AboutView.swift` diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 8d8dc45..eca3935 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = ""; }; 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = ""; }; @@ -169,6 +170,7 @@ 8B4B28C82F6704280045FA19 = { isa = PBXGroup; children = ( + 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */, 8B2FA9CC2F7E211800E609E2 /* Docs */, 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */, 8B8182C22F6B742B000AE049 /* HutchTests */, @@ -515,7 +517,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +534,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.9; + MARKETING_VERSION = 3.1.10; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +554,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +571,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.9; + MARKETING_VERSION = 3.1.10; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +634,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +644,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.9; + MARKETING_VERSION = 3.1.10; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +663,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +673,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1.9; + MARKETING_VERSION = 3.1.10; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme index d05e796..974a7f8 100644 --- a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme @@ -68,6 +68,9 @@ ReferencedContainer = "container:Hutch.xcodeproj"> + + ? - private let productIDs = [ - "net.cleberg.hutch.tip.small", - "net.cleberg.hutch.tip.medium", - "net.cleberg.hutch.tip.large" - ] + init() { + transactionUpdatesTask = Task.detached(priority: .background) { + for await verification in Transaction.updates { + guard case .verified(let transaction) = verification else { continue } + await transaction.finish() + } + } + } + + deinit { + transactionUpdatesTask?.cancel() + } + @MainActor func loadProducts() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil defer { isLoading = false } + do { - let fetched = try await Product.products(for: productIDs) - // Sort by price ascending to maintain small/medium/large order - products = fetched.sorted { $0.price < $1.price } + let fetched = try await Product.products(for: Self.productIDs) + let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) }) + let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] } + let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil } + + products = orderedProducts + + if !missingProducts.isEmpty { + let missingNames = missingProducts.map(\.displayName).joined(separator: ", ") + errorMessage = "Missing products from the App Store response: \(missingNames). Confirm the product identifiers match App Store Connect exactly and that each item is approved or available in sandbox." + } } catch { - errorMessage = error.localizedDescription + products = [] + errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)" } } + @MainActor func purchase(_ product: Product) async { + guard purchasingProductID == nil else { return } + + purchasingProductID = product.id + errorMessage = nil + statusMessage = nil + defer { purchasingProductID = nil } + do { let result = try await product.purchase() switch result { case .success(let verification): - if case .verified(let transaction) = verification { + switch verification { + case .verified(let transaction): await transaction.finish() + statusMessage = "Purchase completed successfully." + case .unverified(_, let error): + errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)" } - case .userCancelled, .pending: + + case .pending: + statusMessage = "Purchase is pending approval." + + case .userCancelled: break + @unknown default: - break + errorMessage = "The App Store returned an unknown purchase result." } } catch { - errorMessage = error.localizedDescription + errorMessage = "Purchase failed. \(error.localizedDescription)" + } + } + + @MainActor + func restorePurchases() async { + guard !isRestoringPurchases else { return } + + isRestoringPurchases = true + errorMessage = nil + defer { isRestoringPurchases = false } + + do { + try await AppStore.sync() + statusMessage = "Purchase history synced with the App Store." + await loadProducts() + } catch { + errorMessage = "Couldn't sync purchases. \(error.localizedDescription)" } } + + @MainActor + func clearStatusMessage() { + statusMessage = nil + } + + func isPurchasing(_ product: Product) -> Bool { + purchasingProductID == product.id + } } diff --git a/HutchTests/TipStoreViewModelTests.swift b/HutchTests/TipStoreViewModelTests.swift new file mode 100644 index 0000000..ed58d1e --- /dev/null +++ b/HutchTests/TipStoreViewModelTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import Hutch + +struct TipStoreViewModelTests { + + @Test + func tipProductIdentifiersMatchAppStoreConnectOrder() { + #expect(TipStoreViewModel.productIDs == [ + "net.cleberg.hutch.tip.small", + "net.cleberg.hutch.tip.medium", + "net.cleberg.hutch.tip.large", + ]) + } + + @Test + func tipProductDisplayNamesMatchReviewMetadata() { + #expect(TipStoreViewModel.TipProduct.small.displayName == "Small Tip") + #expect(TipStoreViewModel.TipProduct.medium.displayName == "Medium Tip") + #expect(TipStoreViewModel.TipProduct.large.displayName == "Large Tip") + } +} diff --git a/SyncedProducts.storekit b/SyncedProducts.storekit new file mode 100644 index 0000000..6d8ae4c --- /dev/null +++ b/SyncedProducts.storekit @@ -0,0 +1,87 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "8055EC65", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "4.99", + "familyShareable" : false, + "internalID" : "6762209132", + "localizations" : [ + { + "description" : "Large Tip", + "displayName" : "Large Tip", + "locale" : "en_US" + } + ], + "productID" : "net.cleberg.hutch.tip.large", + "referenceName" : "Large Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "2.99", + "familyShareable" : false, + "internalID" : "6762209073", + "localizations" : [ + { + "description" : "Medium Tip", + "displayName" : "Medium Tip", + "locale" : "en_US" + } + ], + "productID" : "net.cleberg.hutch.tip.medium", + "referenceName" : "Medium Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "6762209018", + "localizations" : [ + { + "description" : "Small Tip", + "displayName" : "Small Tip", + "locale" : "en_US" + } + ], + "productID" : "net.cleberg.hutch.tip.small", + "referenceName" : "Small Tip", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "6760742299", + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_developerTeamID" : "ZCNAX3VL9D", + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 798317262.14421904, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 5, + "minor" : 0 + } +} -- cgit v1.2.3