SubSovereign
All guides

SubSovereign na integração em iOS — guia

Este guia leva o seu app Apple (iPhone, iPad, Apple TV ou Mac) de "não tenho ideia de quem me pagou" a "o meu app desbloqueia as funcionalidades certas para o utilizador certo, verificado no meu próprio servidor". Está escrito para ser seguido do início ao fim — não é necessário ter experiência prévia com ferramentas de subscrição.

O que o SubSovereign faz por si

Os seus utilizadores subscrevem através da App Store. O SubSovereign responde, de forma fiável, a uma única pergunta para o seu app: o que é que este utilizador pagou, na realidade?

  • O seu app pergunta ao SubSovereign pelo direito de acesso do utilizador — o acesso que foi desbloqueado.
  • A validação da recibo é feita no servidor, diretamente com a Apple (App Store Server API), de modo a que um app modificado não possa falsificar uma subscrição.
  • O ecrã de pagamento — o ecrã que oferece os seus planos — é configurado remotamente, permitindo-lhe alterar preços, períodos de teste e texto sem lançar uma nova versão do app.
  • A função de desistência legal — o controlo de cancelamento obrigatório — também é desenhado pelo SDK, na língua do cliente, de modo a que uma notificação legal nunca possa ser suavizada para marketing.
  • É auto-hospedado: o SubSovereign funciona na sua infraestrutura, os dados dos seus utilizadores ficam consigo e não há partilha de receitas — fica com 100% do que os seus utilizadores pagam.

Nunca confia no dispositivo. O dispositivo pergunta; o servidor decide. O mesmo código funciona em iOS, tvOS (Apple TV) e macOS.

Antes de começar

Precisa de:

  1. Um servidor SubSovereign em execução (a sua implementação auto-hospedada) — o SDK aponta para a respetiva URL.
  2. Um app registado no painel de controlo, que lhe fornece um appId e uma chave de API (a credencial que o seu app usa para comunicar com o servidor).
  3. Níveis de acesso criados — os níveis de acesso (tier) que o seu app concede, por exemplo, pro, cada um ligado no painel de controlo aos IDs de produto da App Store que os seus utilizadores compram.
  4. StoreKit 2 configurado. O SubSovereign valida e regista compras; não substitui o StoreKit. Trate das compras com o StoreKit 2 como habitualmente — o SubSovereign atua logo após uma transação bem-sucedida para a verificar e registar.

Do lado do código, precisa de Swift concorrência (async/await) e de um alvo de implementação de iOS 15 / tvOS 15 / macOS 12 ou superior.

Passo 1 — Adicionar o SDK

Adicione o SubSovereign com o Swift Package Manager — no Xcode, em File ▸ Add Packages… e aponte para o pacote do SDK SubSovereign (ou adicione-o às dependências do seu Package.swift). Depois, importe-o:

import SubSovereign

Passo 2 — Configurar uma única vez, quando o app iniciar

Configure o SDK uma única vez — um bom local é o init da sua App, ou logo após o utilizador iniciar sessão. Forneça-lhe a sua chave de API, o ID do app, a URL do servidor, um identificador estável para este utilizador e a respetiva língua.

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
    )
)

Alguns pontos importantes a saber:

  • userId é seu. Use qualquer ID estável que já tenha para um utilizador autenticado e o mesmo valor todas as vezes, de modo a que o acesso siga o utilizador entre dispositivos. Se um utilizador diferente iniciar sessão, chame configure novamente com o novo userId.
  • baseURL aponta para o seu servidor — o valor predefinido do SDK é um espaço reservado; defina a sua própria implementação.
  • O SDK está anotado com @MainActor, pelo que deve chamá-lo do ator principal (as vistas SwiftUI e .task são adequadas).

Passo 3 — Verificar o que o utilizador pode aceder

Chame checkEntitlements() para descobrir o que o utilizador desbloqueou. Faça isto ao iniciar o app e novamente após uma compra. Trata-se de uma chamada async que pode throw, pelo que a envolva em 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 é a resposta rápida sim/não ao resultado que já obteve. Para proteger uma funcionalidade numa única linha, use o método com o mesmo nome — responde com false em caso de erro, pelo que um problema de rede não abre nada indevidamente:

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

Use-o para uma proteção simples de uma funcionalidade. Para a decisão de acesso global do app, use checkEntitlements() e as orientações sobre Tratamento de erros abaixo — durante uma interrupção, quer manter um cliente pagante com o último acesso conhecido, não quer bloqueá-lo.

Se o seu app tiver mais do que um tier, consulte result.entitlements — cada um deles indica o nível de acesso ativo. Faça a correspondência com o nome que atribuiu ao nível no painel de controlo; accessLevelId é o ID interno do servidor, não esse nome:

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

Cada direito de acesso também inclui expiresAt, willRenew e a store de onde veio. O resultado também inclui fromCache, que indica se o servidor respondeu a partir do seu próprio cache — o SDK não tem cache e cada chamada é enviada para o seu servidor.

Passo 4 — Vender uma subscrição

Mostrar o ecrã de pagamento

Obtenha o ecrã de pagamento do servidor em vez de codificar os preços, de modo a poder fazer uma promoção ou alterar um período de teste sem lançar uma nova versão:

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

PaywallConfig fornece-lhe um headline, subheadline, uma lista de features, os products a oferecer (cada um com um displayPrice, period, trialDays e badge opcional), o texto de chamada à ação e o texto do rodapé. Pode construir o ecrã sozinho a partir destes elementos — ou deixar que o SDK o desenhe. PaywallView desenha o ecrã de pagamento que desenhou no painel de controlo com SwiftUI nativo, nas cores do inquilino, num iPhone, iPad ou Apple TV, e chama-o de volta com o produto escolhido pelo cliente:

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

Uma cor que não seja uma cor nunca chega ao ecrã: a vista escolhe uma alternativa legível e o nosso próprio texto desvanecido mantém-se acima do limite de legibilidade, independentemente das cores que escolher.

Concluir a compra e, depois, verificar

Efetue a compra através do StoreKit 2 como habitualmente. Quando obtiver uma Transaction verificada de volta, passe-a ao SubSovereign para que o servidor a possa validar diretamente com a Apple e conceder o nível de acesso:

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
    }
}

Este é o modelo de confiança completo: a compra só é real quando o servidor a confirmou com a Apple.

Passo 5 — A função de desistência legal (Passaporte de Conformidade)

Se vender subscrições a consumidores da UE, a Diretiva (UE) 2023/2673 exige uma função de desistência legal claramente identificada — o controlo de cancelamento. O SDK inclui-o pronto a usar e é desenhado em duas partes: obtenha as definições para o seu app e, depois, desenhe o controlo com elas.

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)")
            }
        }
    }
}

Não pode alterar a redação. As palavras vêm do seu servidor SubSovereign na língua do cliente e não há forma de passar o seu próprio texto: um parâmetro label que aceitava texto livre foi ignorado, com um aviso registado para o integrador. Uma notificação legal que possa ser reescrita é uma que pode ser suavizada para marketing, razão pela qual também não pode passar uma cor.

🚩 Passe strings, ou a notificação legal aparecerá em inglês para todos os clientes. O controlo desenha aquilo que recebe e não obtém as palavras sozinho — o mesmo contrato tem de se manter em todas as plataformas, incluindo uma em que um componente de IU não pode efetuar rede. A língua que obtém é decidida pela locale que configurou no Passo 2, porque a chamada de definições envia-a. Se a obtenção falhar, o controlo ainda aparece, em inglês, em vez de não aparecer: não disponibilizar uma função de desistência encontrável ao consumidor é uma sua violação; mostrá-la na língua errada, não. Registe o erro; nunca o mostre ao cliente.

Aspeto — .light, .dark ou .auto (valor predefinido), e nada mais. O controlo pinta o seu próprio cartão e o seu próprio texto, pelo que é legível em qualquer ecrã. .auto segue o aspeto do dispositivo num iPhone, iPad ou Mac e é sempre escuro num Apple TV. Não há forma de passar uma cor.

Uma limitação atual no Apple. A data no reconhecimento é formatada de acordo com a localidade do dispositivo, em vez da do cliente.

A etiqueta é sua para escolher, não para escrever. labelKey é a escolha que fez no painel de controlo entre as duas etiquetas sancionadas — Withdraw from contract here ou cancelar aqui — e o controlo mostra as palavras do servidor para esta etiqueta na língua do cliente. Se desenhar o seu próprio controlo em vez de WithdrawalView, submeta através de try await SubSovereign.shared.withdraw(subscriptionId:); é exatamente o que o controlo faz e devolve o mesmo WithdrawalReceipt.

Mostra o botão etiquetado, confirma uma vez — sem oferta de retenção ou inquérito antes, como exige a lei — envia idempotentemente (pressionar Tentar novamente após uma falha reutiliza o mesmo ID, pelo que uma nova tentativa não pode criar uma segunda desistência) e mostra o reconhecimento com a respetiva data. A decisão sobre enabled cabe a si, como acima; o controlo não efetua qualquer rede para além da submissão.

Passo 6 — Privacidade e GDPR

Registe o consentimento onde o recolher e cumpra as expetativas da Apple relativamente aos direitos de dados — o SDK expõe o consentimento, a opção de não venda CCPA, a eliminação e a exportação:

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 tem de ser um de analytics, marketing, personalisation ou consumption_data_sharing — exatamente estes, em minúsculas. Qualquer outro valor lança .invalidArgument e nada é registado: um registo de consentimento é um registo legal e o SDK não vai registar um para um propósito sobre o qual o seu cliente nunca foi questionado. jurisdiction tem como predefinição "GDPR" e policyVersion "1.0" — passe os seus próprios valores se forem diferentes.

Passo 7 — Marcadores de funcionalidades e atribuição

Os marcadores que definiu no painel de controlo e a atribuição do primeiro contacto. Ambos são de tentativa de melhor esforço: nunca lançam erros e, quando algo corre mal, indicam-no no registo unificado (subsistema com.subsovereign.sdk) em vez de ao cliente — um marcador que falta, não um booleano, ou inacessível, é lido como desativado.

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")

Tratamento de erros

Todas as chamadas que lançam erros falham com um SubSovereignError tipificado — .notConfigured, .networkError(Error), .serverError(code, message) ou .invalidArgument(message). Intercepte-o e decida o que fazer em vez de deixar que surja como uma falha:

  • Sucesso — use o valor.
  • Falha — registe-a, mantenha o utilizador com o último acesso conhecido, tente novamente mais tarde. Nunca bloqueie um utilizador pagante por causa de um problema momentâneo de rede.

Melhores práticas

  • Verificar ao iniciar. Chame checkEntitlements() quando o app iniciar (por exemplo, a partir de um .task SwiftUI) para que a proteção esteja correta antes de o utilizador aceder a uma funcionalidade protegida.
  • Verificar novamente após comprar. Logo após uma validateApplePurchase bem-sucedida, chame novamente checkEntitlements() para que a IU se atualize imediatamente.
  • Nunca confie no cliente. Não armazene "é pro" no app e trate-o como verdade — pergunte ao servidor; o servidor verificou a transação com a Apple.
  • Um userId por utilizador real. Mantenha-o estável para que o acesso siga o utilizador entre os seus dispositivos Apple e reconfigurar quando o utilizador autenticado mudar.

Referência rápida

Pretende… Chame
Configurar o SDK SubSovereign.shared.configure(config)
Ver o que o utilizador desbloqueou try await checkEntitlements()EntitlementResult
Mostrar o ecrã de pagamento remoto try await getPaywallConfig()PaywallConfig
Desenhar o ecrã de pagamento de forma nativa PaywallView(config:onSelectProduct:)
Verificar uma compra StoreKit 2 try await validateApplePurchase(transaction:accessLevelId:)
Concluir uma transação await finishTransaction(transaction)
Proteger uma funcionalidade, falhando em segurança (fail-closed) await hasAccess(accessLevelName:)Bool
Obter as definições de desistência try await getWithdrawalConfig()WithdrawalConfig
Desenhar o controlo de cancelamento legal WithdrawalView(subscriptionId:strings:labelKey:appearance:)
Submeter uma desistência a partir do seu próprio controlo try await withdraw(subscriptionId:)WithdrawalReceipt
Ler os marcadores de funcionalidades do painel de controlo await getFeatureFlags()[String: Bool]
Registar a atribuição do primeiro contacto await recordAttributionTouch(utmSource:utmCampaign:)
Registar o consentimento GDPR try await recordConsent(purpose:granted:)
Cumprir um pedido CCPA de não venda try await setDoNotSell(enabled:)
Eliminar / exportar os dados de um utilizador try await requestErasure() / exportMyData()

Próximos passos