SubSovereign
All guides

SubSovereign localizzazione su iOS — guida all’integrazione

Questa guida accompagna la tua app Apple (iPhone, iPad, Apple TV o Mac) da “non so chi mi ha pagato” a “la mia app sblocca le funzionalità giuste per l’utente giusto, verificato sul mio server”. È scritta per essere seguita dall’inizio alla fine, senza richiedere esperienza pregressa con strumenti per abbonamenti.

Cosa fa SubSovereign per te

I tuoi utenti si abbonano tramite l’App Store. SubSovereign risponde in modo affidabile a una sola domanda per la tua app: cosa ha effettivamente pagato questo utente?

  • La tua app chiede a SubSovereign il diritto di accesso dell’utente — ciò che ha sbloccato.
  • La validazione della ricevuta avviene sul server, direttamente con Apple (tramite l’App Store Server API), quindi un’app modificata non può falsificare un abbonamento.
  • Il paywall — la schermata che offre i tuoi piani — è configurato da remoto, quindi puoi modificare prezzi, prove gratuite e testi senza pubblicare una nuova versione dell’app.
  • La funzione di recesso dalla legge — il controllo di cancellazione obbligatorio — è gestita anch’essa dall’SDK, nella lingua dell’utente, per evitare che una comunicazione legale venga attenuata in messaggio promozionale.
  • È self-hosted: SubSovereign gira sulla tua infrastruttura, i dati dei tuoi utenti restano con te e non c’è alcuna percentuale sul ricavo — tu trattengo il 100% di ciò che pagano gli utenti.

Non ti fidi del dispositivo. Il dispositivo chiede; il server decide. Lo stesso codice funziona su iOS, tvOS (Apple TV) e macOS.

Prima di iniziare

Avrai bisogno di:

  1. Un server SubSovereign in esecuzione (la tua distribuzione self-hosted) — l’SDK punta al suo URL.
  2. Un’app registrata nella dashboard, che ti fornisce un appId e una chiave API (la credenziale che la tua app usa per comunicare con il server).
  3. Livelli di accesso creati — i livelli di accesso (tier) che la tua app concede, ad esempio pro, ciascuno collegato nella dashboard agli ID dei prodotti dell’App Store che gli utenti acquistano.
  4. StoreKit 2 configurato. SubSovereign valida e traccia gli acquisti; non sostituisce StoreKit. Gestisci gli acquisti con StoreKit 2 come di consueto — SubSovereign interviene subito dopo una transazione andata a buon fine per verificarla e registrarla.

Dal punto di vista del codice, ti servono Swift concurrency (async/await) e un target di distribuzione di iOS 15 / tvOS 15 / macOS 12 o versioni successive.

Passo 1 — Aggiungi l’SDK

Aggiungi SubSovereign tramite Swift Package Manager — in Xcode, vai su File ▸ Aggiungi pacchetti… e punta al pacchetto dell’SDK SubSovereign (o aggiungilo alle dipendenze del tuo Package.swift). Poi importalo:

import SubSovereign

Passo 2 — Configura una sola volta, all’avvio dell’app

Configura l’SDK una sola volta — un buon punto è l’inizializzazione della tua App, o subito dopo l’accesso dell’utente. Forniscigli la tua chiave API, l’ID dell’app, l’URL del tuo server, un identificatore stabile per questo utente e la sua lingua.

SubSovereign.shared.configure(
    SubSovereignConfig(
        apiKey:  "YOUR_APP_API_KEY",                 // from the dashboard
        appId:   "your-app-id",                      // from the dashboard
        baseURL: "https://subs.yourdomain.com/api/v1", // YOUR self-hosted server
        userId:  currentUser.id,                     // your own stable user id
        locale:  Locale.current.identifier           // the user's language
    )
)

Ecco alcune cose da sapere:

  • userId è tuo. Usa qualsiasi ID stabile che già utilizzi per un utente autenticato e lo stesso valore ogni volta, in modo che l’accesso segua l’utente tra i dispositivi. Se un utente diverso si autentica, richiama configure di nuovo con il nuovo userId.
  • baseURL punta al tuo server — il valore predefinito dell’SDK è un segnaposto; imposta il tuo deployment.
  • L’SDK è annotato con @MainActor, quindi chiamalo dal main actor (le viste SwiftUI e .task sono adatte).

Passo 3 — Verifica cosa può fare l’utente

Chiama checkEntitlements() per scoprire cosa l’utente ha sbloccato. Fallo all’avvio e di nuovo subito dopo un acquisto. È una chiamata async che può generare un errore (throw), quindi avvolgila in un do/catch:

do {
    let result = try await SubSovereign.shared.checkEntitlements()
    if result.hasAccess {
        unlockProFeatures()          // the user has paid access
    } else {
        showFreeExperience()         // free tier / show a paywall
    }
} catch {
    // Network hiccup or server error. Fail gracefully — usually keep the user on
    // whatever access they last had, and try again later.
    print("Entitlement check failed: \(error.localizedDescription)")
}

result.hasAccess è il sì/no rapido sul risultato che hai già ottenuto. Per proteggere una singola funzionalità in una riga, usa il metodo con lo stesso nome — restituisce false in caso di errore, quindi un problema di rete non sblocca nulla:

if await SubSovereign.shared.hasAccess(accessLevelName: "pro") {
    unlockProFeatures()
}

Usalo per una protezione economica su una singola funzionalità. Per la decisione di accesso a livello di app, usa checkEntitlements() e i consigli su Gestione degli errori qui sotto — durante un’interruzione, vuoi che un cliente pagante rimanga con l’accesso noto per ultimo, non che venga bloccato.

Se la tua app ha più livelli, guarda dentro result.entitlements — ciascuno indica il livello di accesso attivo. Abbinalo al nome che hai assegnato al livello nella dashboard; accessLevelId è l’ID interno del server, non quel nome:

let isPro = result.entitlements.contains { $0.isActive && $0.accessLevelName.lowercased() == "pro" }

Ogni diritto di accesso include anche expiresAt, willRenew e lo store da cui proviene. Il risultato include anche fromCache, che segnala se il server ha risposto dalla sua cache — l’SDK stesso non memorizza cache e ogni chiamata va al tuo server.

Passo 4 — Vendi un abbonamento

Mostra il paywall

Recupera il paywall dal server invece di codificare i prezzi, così puoi avviare una promozione o modificare una prova gratuita senza rilasciare una nuova versione:

if let paywall = try? await SubSovereign.shared.getPaywallConfig() {
    renderPaywall(paywall)   // headline, features, products…
} else {
    renderFallbackPaywall()  // your built-in default
}

PaywallConfig ti fornisce una headline, una subheadline, una lista di features, i products da offrire (ognuno con un displayPrice, period, trialDays e un badge opzionale), il testo della call-to-action e il testo del piè di pagina. Puoi costruire tu stesso la schermata da questi dati — o lasciare che l’SDK la disegni. PaywallView renderizza il paywall che hai progettato nella dashboard con SwiftUI nativo, nei colori del tenant, su iPhone, iPad o Apple TV, e ti restituisce il prodotto scelto dal cliente:

PaywallView(config: paywall) { productId in
    // start the StoreKit purchase for `productId` - see "Complete the purchase" below
}

Un colore che non è un colore non raggiunge mai lo schermo: la vista sceglie un’alternativa leggibile e il nostro testo sbiadito viene mantenuto sopra il limite di leggibilità, indipendentemente dai colori che hai scelto.

Completa l’acquisto, poi verificalo

Esegui l’acquisto tramite StoreKit 2 come faresti normalmente. Quando ottieni una Transaction verificata, passala a SubSovereign in modo che il server possa validarla direttamente con Apple e concedere il livello di accesso:

func buy(_ product: Product, accessLevelId: String) async throws {
    let result = try await product.purchase()
    guard case .success(let verification) = result else { return }   // cancelled, or pending approval
    guard case .verified(let transaction) = verification else {
        // .unverified means StoreKit could not trust the signature — a tampered receipt.
        // Never grant access, and never let this pass silently.
        print("Refused an unverified transaction")
        return
    }

    let granted = try await SubSovereign.shared.validateApplePurchase(
        transaction: transaction,
        accessLevelId: accessLevelId      // required; the SERVER grants whatever tier your
                                          // dashboard maps this product to, not this value
    )
    if granted {
        await SubSovereign.shared.finishTransaction(transaction)  // tell StoreKit it's done
        _ = try await SubSovereign.shared.checkEntitlements()      // re-check, then unlock
    }
}

Questo è l’intero modello di fiducia: l’acquisto è reale solo dopo che il server lo ha confermato con Apple.

Passo 5 — La funzione di recesso dalla legge (Compliance Passport)

Se vendi abbonamenti a consumatori dell’UE, la Direttiva (UE) 2023/2673 richiede una funzione di recesso chiaramente etichettata — il controllo di cancellazione. L’SDK la fornisce già pronta e viene disegnata in due fasi: recupera le impostazioni per la tua app, poi disegna il controllo con esse.

struct CancelSubscription: View {
    let subscriptionId: String
    @State private var settings: WithdrawalConfig?

    var body: some View {
        Group {
            if settings?.enabled == false {
                EmptyView()                        // switched off for this app in the console
            } else {
                WithdrawalView(
                    subscriptionId: subscriptionId,
                    labelKey: settings?.labelKey,  // the label you chose in the console
                    appearance: .auto              // .light, .dark or .auto — and nothing else
                )
                // The WORDS are not passed in. getWithdrawalConfig() below fetches them in your
                // customer's language and the SDK keeps them; this screen reads them from there.
                // Fetch FIRST — without it the notice falls back to English for everyone.
            }
        }
        .task {
            do {
                settings = try await SubSovereign.shared.getWithdrawalConfig()
            } catch {
                // Log it: a silent failure here is how you ship English to German customers
                // and never find out.
                print("Withdrawal settings unavailable: \(error.localizedDescription)")
            }
        }
    }
}

Non puoi modificare il testo. Le parole provengono dal tuo server SubSovereign nella lingua del cliente e non c’è modo di passare un testo personalizzato: un parametro label che in passato accettava testo libero ora è ignorato, con un avviso registrato nell’integratore. Una comunicazione legale che può essere riformulata è una comunicazione che può essere attenuata in messaggio promozionale, ed è per questo che non puoi passare un colore.

🚩 Passa le strings, altrimenti la comunicazione di recesso sarà in inglese per ogni cliente. Il controllo visualizza ciò che gli viene passato e non recupera le parole da solo — lo stesso contratto deve valere su ogni piattaforma, inclusa quella in cui un componente UI non può fare rete. La lingua che ottieni è decisa dal locale che hai configurato al Passo 2, perché la chiamata delle impostazioni lo invia. Se il recupero fallisce, il controllo appare comunque, in inglese, piuttosto che non apparire: non fornire una funzione di recesso visibile al consumatore è una tua violazione; mostrarla nella lingua sbagliata non lo è. Registra l’errore; non mostrarlo mai al cliente.

Aspetto — .light, .dark o .auto (predefinito), e nient’altro. Il controllo disegna la propria scheda e il proprio testo, quindi è leggibile su qualsiasi schermo. .auto segue l’aspetto del dispositivo su iPhone, iPad o Mac ed è sempre scuro su Apple TV. Non c’è modo di passare un colore.

Un limite su Apple oggi. La data sull’avviso è formattata secondo il locale del dispositivo, non del cliente.

Il label è una tua scelta, non una tua scrittura. labelKey è la scelta che hai fatto nella console tra le due etichette autorizzate — Recedi dal contratto qui o Disdici qui — e il controllo mostra le parole del server per esso nella lingua del cliente. Se disegni tu stesso il controllo invece di WithdrawalView, invia tramite try await SubSovereign.shared.withdraw(subscriptionId:); è esattamente ciò che fa il controllo e restituisce la stessa WithdrawalReceipt.

Mostra il pulsante etichettato, conferma una volta — senza offerte di retention o sondaggi prima, come richiede la legge — invia idempotentemente (premere Riprova dopo un errore riutilizza lo stesso ID, quindi un nuovo tentativo non può creare un secondo recesso), e mostra l’avviso con la sua data. Spetta a te applicare un controllo su enabled, come sopra; il controllo stesso non fa rete oltre alla sottomissione.

Passo 6 — Privacy e GDPR

Registra il consenso dove lo raccogli e rispetta le aspettative di Apple in materia di diritti sui dati — l’SDK espone il consenso, l’interruttore CCPA per la non vendita, la cancellazione e l’esportazione:

try await SubSovereign.shared.recordConsent(purpose: "analytics", granted: true)

// CCPA "do not sell" / Global Privacy Control.
// `true` turns the customer's OPT-OUT on; pass `false` when they opt back in.
try await SubSovereign.shared.setDoNotSell(enabled: true)

// If the user asks to be forgotten / to get their data:
try await SubSovereign.shared.requestErasure()
let myData = try await SubSovereign.shared.exportMyData()

purpose deve essere uno tra analytics, marketing, personalisation o consumption_data_sharing — esattamente questi, in minuscolo. Tutto il resto genera un errore .invalidArgument e nulla viene registrato: un consenso è un atto legale e l’SDK non registrerà un consenso per uno scopo per cui il cliente non è stato interpellato. jurisdiction predefinito è "GDPR" e policyVersion è "1.0" — passali tuoi se differiscono.

Passo 7 — Feature flag e attribuzione

I flag che imposti nella dashboard e l’attribuzione del primo contatto. Entrambi sono best-effort: non generano mai errori e, in caso di problema, lo segnalano nel log unificato (sottosistema com.subsovereign.sdk) piuttosto che all’utente — un flag mancante, non un booleano, o irraggiungibile viene interpretato come spento.

let flags = await SubSovereign.shared.getFeatureFlags()
if flags["new_paywall"] == true { showNewPaywall() }

// First touch wins on the server, so this is safe to call on every launch.
await SubSovereign.shared.recordAttributionTouch(utmSource: "newsletter", utmCampaign: "spring")

Gestione degli errori

Ogni chiamata che genera errori restituisce un SubSovereignError tipizzato — .notConfigured, .networkError(Error), .serverError(code, message) o .invalidArgument(message). Intercettalo e decidi cosa fare invece di lasciarlo emergere come crash:

  • Successo — usa il valore.
  • Fallimento — registralo, mantieni l’utente con il suo ultimo accesso noto, riprova dopo. Non bloccare mai un utente pagante perché di un problema di rete momentaneo.

Best practice

  • Verifica all’avvio. Chiama checkEntitlements() quando l’app si avvia (ad esempio da un .task SwiftUI) in modo che la protezione sia corretta prima che l’utente raggiunga una funzionalità bloccata.
  • Verifica dopo l’acquisto. Subito dopo una validateApplePurchase andata a buon fine, richiama checkEntitlements() di nuovo in modo che l’interfaccia si aggiorni immediatamente.
  • Non fidarti del client. Non memorizzare “è pro” nell’app e trattarlo come verità — chiedi al server; il server ha verificato la transazione con Apple.
  • Un userId per utente reale. Mantienilo stabile in modo che l’accesso segua l’utente tra i suoi dispositivi Apple e riconfigura quando cambia l’utente autenticato.

Riferimento rapido

Vuoi… Chiama
Configurare l’SDK SubSovereign.shared.configure(config)
Vedere cosa ha sbloccato l’utente try await checkEntitlements()EntitlementResult
Mostrare il paywall da remoto try await getPaywallConfig()PaywallConfig
Disegnare il paywall in modo nativo PaywallView(config:onSelectProduct:)
Verificare un acquisto StoreKit 2 try await validateApplePurchase(transaction:accessLevelId:)
Completare una transazione await finishTransaction(transaction)
Proteggere una funzionalità, fail-closed await hasAccess(accessLevelName:)Bool
Recuperare le impostazioni di recesso try await getWithdrawalConfig()WithdrawalConfig
Disegnare il controllo di cancellazione obbligatorio WithdrawalView(subscriptionId:strings:labelKey:appearance:)
Inviare un recesso dal tuo controllo try await withdraw(subscriptionId:)WithdrawalReceipt
Leggere i feature flag della dashboard await getFeatureFlags()[String: Bool]
Registrare l’attribuzione del primo contatto await recordAttributionTouch(utmSource:utmCampaign:)
Registrare il consenso GDPR try await recordConsent(purpose:granted:)
Rispettare una richiesta CCPA di non vendita try await setDoNotSell(enabled:)
Cancellare / esportare i dati di un utente try await requestErasure() / exportMyData()

Passi successivi