Perché gli acquisti in-app

Monetizzare un'app mobile passa spesso dagli acquisti in-app (IAP): sblocco di funzionalità premium, contenuti extra o abbonamenti ricorrenti. Flutter offre il pacchetto ufficiale in_app_purchase, che astrae le API di StoreKit (iOS) e Google Play Billing (Android) dietro un'interfaccia unificata.

In questa guida vediamo come configurare i prodotti, caricarli, avviare un acquisto e verificarlo lato client.

Tipi di prodotto

  • Consumabili: acquistabili più volte (es. monete di gioco).
  • Non consumabili: acquisto una tantum (es. rimozione pubblicità).
  • Abbonamenti: rinnovo automatico ricorrente.

Configurazione

Aggiungi la dipendenza:

dependencies:
  in_app_purchase: ^3.2.0

Su Android non servono modifiche particolari al AndroidManifest.xml per le versioni recenti del plugin. Su iOS assicurati di abilitare la capability In-App Purchase in Xcode.

I prodotti vanno prima creati nelle rispettive console:

  • Google Play Console → sezione Prodotti in-app / Abbonamenti.
  • App Store Connect → sezione In-App Purchases.

Usa gli stessi identificatori di prodotto (productId) su entrambe le piattaforme per semplificare il codice.

Verificare la disponibilità dello store

import 'package:in_app_purchase/in_app_purchase.dart';

final InAppPurchase _iap = InAppPurchase.instance;

Future<bool> isStoreAvailable() async {
  return _iap.isAvailable();
}

Caricare i prodotti

const Set<String> _kIds = {'remove_ads', 'premium_monthly'};

Future<List<ProductDetails>> loadProducts() async {
  final ProductDetailsResponse response =
      await _iap.queryProductDetails(_kIds);

  if (response.notFoundIDs.isNotEmpty) {
    debugPrint('ID non trovati: ${response.notFoundIDs}');
  }
  return response.productDetails;
}

Ogni ProductDetails contiene id, title, description e price (già formattato nella valuta locale dello store).

Ascoltare gli aggiornamenti degli acquisti

Il flusso di acquisto è asincrono: dobbiamo iscriverci allo stream purchaseStream prima di avviare qualsiasi transazione, tipicamente all'avvio dell'app.

late final StreamSubscription<List<PurchaseDetails>> _subscription;

void initPurchaseListener() {
  _subscription = _iap.purchaseStream.listen(
    _onPurchaseUpdate,
    onDone: () => _subscription.cancel(),
    onError: (error) => debugPrint('Errore acquisto: $error'),
  );
}

Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchases) async {
  for (final purchase in purchases) {
    switch (purchase.status) {
      case PurchaseStatus.pending:
        _showPending();
        break;
      case PurchaseStatus.purchased:
      case PurchaseStatus.restored:
        await _verifyAndDeliver(purchase);
        break;
      case PurchaseStatus.error:
        debugPrint('Errore: ${purchase.error}');
        break;
      case PurchaseStatus.canceled:
        debugPrint('Acquisto annullato');
        break;
    }

    // Fondamentale: completare la transazione
    if (purchase.pendingCompletePurchase) {
      await _iap.completePurchase(purchase);
    }
  }
}

⚠️ Chiamare completePurchase è obbligatorio: se non lo fai, lo store considererà la transazione non consegnata e la ripresenterà a ogni avvio.

Avviare un acquisto

Future<void> buy(ProductDetails product, {required bool isConsumable}) async {
  final PurchaseParam param = PurchaseParam(productDetails: product);

  if (isConsumable) {
    await _iap.buyConsumable(purchaseParam: param);
  } else {
    await _iap.buyNonConsumable(purchaseParam: param);
  }
}

Gli abbonamenti si acquistano con buyNonConsumable.

Verifica lato server

La validazione fatta solo sul client è vulnerabile. La verifica corretta prevede l'invio del purchase.verificationData.serverVerificationData a un backend che interroga le API di Google/Apple.

Future<void> _verifyAndDeliver(PurchaseDetails purchase) async {
  final token = purchase.verificationData.serverVerificationData;
  final valid = await backend.verify(purchase.productID, token);

  if (valid) {
    // Sblocca il contenuto e persisti lo stato
    await _deliverProduct(purchase.productID);
  }
}

Ripristinare gli acquisti

Apple richiede un pulsante "Ripristina acquisti" per i prodotti non consumabili e gli abbonamenti:

Future<void> restorePurchases() async {
  await _iap.restorePurchases();
}

Gli acquisti ripristinati arriveranno sul purchaseStream con stato restored.

Best practice

  • Verifica sempre lato server per prevenire frodi.
  • Non fidarti del client per lo sblocco di contenuti di valore.
  • Testa con account sandbox (App Store) e testers con licenza (Play Console).
  • Ricordati di annullare la subscription allo stream nel dispose.
  • Per gli abbonamenti usa librerie come RevenueCat se vuoi gestione multipiattaforma e webhook già pronti.

Conclusione

Il pacchetto in_app_purchase copre bene i casi standard di monetizzazione mantenendo un'unica API cross-platform. La parte più delicata resta la verifica lato server: non trascurarla se vendi contenuti di valore. Per progetti complessi con molti abbonamenti, valuta soluzioni gestite che riducono il boilerplate e centralizzano l'analisi dei ricavi.