Apple App Store Integration (iOS)
Guida per integrare i 30 piani Visla GPS su iOS con StoreKit 2.
π¦ Setup App Store Connectβ
1. Creare Subscription Groupβ
- App Store Connect β My Apps β Subscriptions
- Crea gruppo: "Visla GPS Tracking"
2. Creare i 30 Prodottiβ
Crea ogni subscription nel gruppo:
Mensili (10 prodotti)β
| Product ID | Nome | Prezzo |
|---|---|---|
com.visla.gps.monthly_1 | 1 Dispositivo - Mensile | β¬4,90 |
com.visla.gps.monthly_2 | 2 Dispositivi - Mensile | β¬9,80 |
com.visla.gps.monthly_3 | 3 Dispositivi - Mensile | β¬11,70 |
com.visla.gps.monthly_4 | 4 Dispositivi - Mensile | β¬15,60 |
com.visla.gps.monthly_5 | 5 Dispositivi - Mensile | β¬19,50 |
com.visla.gps.monthly_6 | 6 Dispositivi - Mensile | β¬23,40 |
com.visla.gps.monthly_7 | 7 Dispositivi - Mensile | β¬27,30 |
com.visla.gps.monthly_8 | 8 Dispositivi - Mensile | β¬31,20 |
com.visla.gps.monthly_9 | 9 Dispositivi - Mensile | β¬35,10 |
com.visla.gps.monthly_10 | 10 Dispositivi - Mensile | β¬39,00 |
Semestrali (10 prodotti)β
| Product ID | Nome | Prezzo |
|---|---|---|
com.visla.gps.semiannual_1 | 1 Dispositivo - 6 Mesi | β¬23,50 |
com.visla.gps.semiannual_2 | 2 Dispositivi - 6 Mesi | β¬47,00 |
| ... | ... | ... |
com.visla.gps.semiannual_10 | 10 Dispositivi - 6 Mesi | β¬187,00 |
Annuali (10 prodotti)β
| Product ID | Nome | Prezzo |
|---|---|---|
com.visla.gps.annual_1 | 1 Dispositivo - Annuale | β¬35,00 |
com.visla.gps.annual_2 | 2 Dispositivi - Annuale | β¬70,00 |
| ... | ... | ... |
com.visla.gps.annual_10 | 10 Dispositivi - Annuale | β¬280,00 |
3. Server Notifications v2β
- Production URL:
https://api.vislagps.com/webhooks/apple - Sandbox URL:
https://api-dev.vislagps.com/webhooks/apple - Version: Version 2
π± iOS Implementationβ
Product IDsβ
// Models/SubscriptionPlans.swift
struct SubscriptionPlans {
static let productIds: [String] = {
var ids: [String] = []
for devices in 1...10 {
ids.append("com.visla.gps.monthly_\(devices)")
ids.append("com.visla.gps.semiannual_\(devices)")
ids.append("com.visla.gps.annual_\(devices)")
}
return ids
}()
static func productId(devices: Int, period: Period) -> String {
return "com.visla.gps.\(period.rawValue)_\(devices)"
}
enum Period: String {
case monthly = "monthly"
case semiannual = "semiannual"
case annual = "annual"
}
}
Store Managerβ
// Managers/StoreManager.swift
import StoreKit
@MainActor
class StoreManager: ObservableObject {
@Published var products: [Product] = []
@Published var purchasedProductIDs: Set<String> = []
@Published var isLoading = false
private var transactionListener: Task<Void, Error>?
init() {
transactionListener = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
// MARK: - Load All 30 Products
func loadProducts() async {
isLoading = true
do {
products = try await Product.products(for: SubscriptionPlans.productIds)
products.sort { extractDeviceCount($0.id) < extractDeviceCount($1.id) }
} catch {
print("Failed to load products: \(error)")
}
isLoading = false
}
// MARK: - Get Products for Device Count
func productsForDevices(_ count: Int) -> [Product] {
return products.filter { $0.id.contains("_\(count)") }
}
// MARK: - Purchase
func purchase(_ product: Product, userId: Int) async throws -> Bool {
guard let uuid = uuidFromUserId(userId) else {
throw StoreError.invalidUserId
}
let result = try await product.purchase(options: [
.appAccountToken(uuid)
])
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
await updatePurchasedProducts()
return true
case .userCancelled, .pending:
return false
@unknown default:
return false
}
}
// MARK: - Current Subscription
func currentSubscription() async -> Product? {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
transaction.productType == .autoRenewable,
let product = products.first(where: { $0.id == transaction.productID }) {
return product
}
}
return nil
}
func currentDeviceLimit() async -> Int {
guard let product = await currentSubscription() else { return 0 }
return extractDeviceCount(product.id)
}
// MARK: - Helpers
private func extractDeviceCount(_ productId: String) -> Int {
let parts = productId.split(separator: "_")
return Int(parts.last ?? "0") ?? 0
}
private func uuidFromUserId(_ userId: Int) -> UUID? {
let hex = String(format: "%08x", userId)
return UUID(uuidString: "\(hex)-0000-0000-0000-000000000000")
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified: throw StoreError.verificationFailed
case .verified(let safe): return safe
}
}
private func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self.updatePurchasedProducts()
}
}
}
}
private func updatePurchasedProducts() async {
var purchased: Set<String> = []
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
purchased.insert(transaction.productID)
}
}
purchasedProductIDs = purchased
}
}
enum StoreError: Error {
case verificationFailed
case invalidUserId
}
Subscription Viewβ
// Views/SubscriptionView.swift
import SwiftUI
struct SubscriptionView: View {
@EnvironmentObject var storeManager: StoreManager
@EnvironmentObject var authManager: AuthManager
@State private var selectedDevices = 3
@State private var selectedPeriod: SubscriptionPlans.Period = .annual
var selectedProduct: Product? {
let productId = SubscriptionPlans.productId(devices: selectedDevices, period: selectedPeriod)
return storeManager.products.first { $0.id == productId }
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Device Selector
DeviceSelectorView(selectedDevices: $selectedDevices)
// Period Selector
PeriodSelectorView(
selectedPeriod: $selectedPeriod,
devices: selectedDevices,
products: storeManager.products
)
// Subscribe Button
if let product = selectedProduct {
Button {
Task {
try? await storeManager.purchase(
product,
userId: authManager.currentUserId
)
}
} label: {
HStack {
Text("Abbonati")
Text(product.displayPrice)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
}
// Restore
Button("Ripristina Acquisti") {
Task { await storeManager.restorePurchases() }
}
.foregroundColor(.secondary)
}
.padding()
}
.navigationTitle("Abbonamento")
}
}
struct DeviceSelectorView: View {
@Binding var selectedDevices: Int
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quanti dispositivi?")
.font(.headline)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 8) {
ForEach(1...10, id: \.self) { n in
Button("\(n)") {
selectedDevices = n
}
.frame(height: 50)
.frame(maxWidth: .infinity)
.background(selectedDevices == n ? Color.blue : Color(.systemGray5))
.foregroundColor(selectedDevices == n ? .white : .primary)
.cornerRadius(8)
}
}
}
}
}
struct PeriodSelectorView: View {
@Binding var selectedPeriod: SubscriptionPlans.Period
let devices: Int
let products: [Product]
func price(for period: SubscriptionPlans.Period) -> String {
let productId = SubscriptionPlans.productId(devices: devices, period: period)
return products.first { $0.id == productId }?.displayPrice ?? "-"
}
var body: some View {
VStack(spacing: 8) {
PeriodRow(
title: "Mensile",
price: price(for: .monthly),
isSelected: selectedPeriod == .monthly
) { selectedPeriod = .monthly }
PeriodRow(
title: "Semestrale",
price: price(for: .semiannual),
badge: "-20%",
isSelected: selectedPeriod == .semiannual
) { selectedPeriod = .semiannual }
PeriodRow(
title: "Annuale",
price: price(for: .annual),
badge: "-40%",
isSelected: selectedPeriod == .annual
) { selectedPeriod = .annual }
}
}
}
π Punti Criticiβ
- appAccountToken: Passa sempre l'user_id convertito in UUID
- 30 prodotti: Creali tutti in App Store Connect
- Subscription Group: Tutti nello stesso gruppo per upgrade/downgrade
β Checklistβ
- Crea Subscription Group "Visla GPS Tracking"
- Crea 30 prodotti (10 Γ 3 periodi)
- Configura Server Notifications v2
- Implementa StoreManager con tutti i product IDs
- Implementa UI selezione dispositivi + periodo
- Testa in Sandbox
- Aggiungi "Ripristina Acquisti"