Skip to main content

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​

  1. App Store Connect β†’ My Apps β†’ Subscriptions
  2. Crea gruppo: "Visla GPS Tracking"

2. Creare i 30 Prodotti​

Crea ogni subscription nel gruppo:

Mensili (10 prodotti)​

Product IDNomePrezzo
com.visla.gps.monthly_11 Dispositivo - Mensile€4,90
com.visla.gps.monthly_22 Dispositivi - Mensile€9,80
com.visla.gps.monthly_33 Dispositivi - Mensile€11,70
com.visla.gps.monthly_44 Dispositivi - Mensile€15,60
com.visla.gps.monthly_55 Dispositivi - Mensile€19,50
com.visla.gps.monthly_66 Dispositivi - Mensile€23,40
com.visla.gps.monthly_77 Dispositivi - Mensile€27,30
com.visla.gps.monthly_88 Dispositivi - Mensile€31,20
com.visla.gps.monthly_99 Dispositivi - Mensile€35,10
com.visla.gps.monthly_1010 Dispositivi - Mensile€39,00

Semestrali (10 prodotti)​

Product IDNomePrezzo
com.visla.gps.semiannual_11 Dispositivo - 6 Mesi€23,50
com.visla.gps.semiannual_22 Dispositivi - 6 Mesi€47,00
.........
com.visla.gps.semiannual_1010 Dispositivi - 6 Mesi€187,00

Annuali (10 prodotti)​

Product IDNomePrezzo
com.visla.gps.annual_11 Dispositivo - Annuale€35,00
com.visla.gps.annual_22 Dispositivi - Annuale€70,00
.........
com.visla.gps.annual_1010 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​

  1. appAccountToken: Passa sempre l'user_id convertito in UUID
  2. 30 prodotti: Creali tutti in App Store Connect
  3. 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"