> ## Documentation Index
> Fetch the complete documentation index at: https://docs.appdna.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Flutter Billing

> In-app purchases, entitlements, and restore

<Info>
  **Supported on:** iOS SDK `1.0.61+` · Android SDK `1.0.33+` · Flutter SDK `1.0.3+`
</Info>

The AppDNA Flutter SDK provides a billing module for managing in-app purchases, subscriptions, entitlement verification, and purchase restoration. All billing logic is handled by the native iOS and Android SDKs through the `com.appdna.sdk/billing` MethodChannel and the `com.appdna.sdk/entitlements` EventChannel.

## Configuration

Set the billing provider when configuring the SDK:

```dart theme={null}
await AppDNA.configure(
  apiKey: 'adn_live_xxx',
  env: AppDNAEnvironment.production,
  options: AppDNAOptions(billingProvider: AppDNABillingProvider.storeKit2),
);
```

### AppDNABillingProvider Options

| Value                                              | Description                                                            |
| -------------------------------------------------- | ---------------------------------------------------------------------- |
| `AppDNABillingProvider.storeKit2`                  | Native StoreKit 2 (iOS) / Play Billing (Android) — default             |
| `AppDNABillingProvider.revenueCat`                 | RevenueCat integration                                                 |
| `AppDNABillingProvider.adapty('<public-sdk-key>')` | Adapty integration — a factory that carries your Adapty public SDK key |
| `AppDNABillingProvider.none`                       | Disable the billing module entirely                                    |

<Warning>
  You must set the `billingProvider` in `AppDNAOptions` before accessing `AppDNA.billing`. If set to `AppDNABillingProvider.none`, billing module methods will throw errors.
</Warning>

## Billing Module

Access the billing module through `AppDNA.billing`, which returns an `AppDNABilling` instance:

```dart theme={null}
final billing = AppDNA.billing;
```

## Get Products

Fetch product information for one or more product identifiers:

```dart theme={null}
final products = await AppDNA.billing.getProducts(["premium_monthly"]);

for (final product in products) {
  print("${product.name}: ${product.displayPrice}");
}
```

Returns `List<ProductInfo>`.

### ProductInfo

| Property       | Type      | Description                                             |
| -------------- | --------- | ------------------------------------------------------- |
| `id`           | `String`  | Product identifier                                      |
| `name`         | `String`  | Localized product name                                  |
| `description`  | `String`  | Localized product description                           |
| `displayPrice` | `String`  | Formatted price string (e.g., "\$9.99/mo")              |
| `price`        | `double`  | Numeric price value                                     |
| `offerToken`   | `String?` | Platform-specific offer token (Android base-plan offer) |

## Purchase a Product

Initiate a purchase for a product:

```dart theme={null}
final result = await AppDNA.billing.purchase(
  "premium_monthly",
  offerToken: "offer-xxx",
);

switch (result.status) {
  case 'purchased':
    print("Purchase successful!");
    if (result.entitlement != null) {
      print("Entitlement: ${result.entitlement!.productId}");
    }
    break;
  case 'cancelled':
    print("User cancelled");
    break;
  case 'pending':
    print("Purchase pending approval");
    break;
  default:
    print("Unknown status: ${result.status}");
}
```

Returns a `PurchaseResult`.

### PurchaseResult

| Property      | Type           | Description                                            |
| ------------- | -------------- | ------------------------------------------------------ |
| `status`      | `String`       | One of: `purchased`, `cancelled`, `pending`, `unknown` |
| `entitlement` | `Entitlement?` | The entitlement granted by the purchase, if any        |

### Transaction Map Keys

The `transaction` payload delivered to `onPurchaseCompleted` (and `AppDNAPaywallDelegate.onPaywallPurchaseCompleted`) is a `Map<String, dynamic>` with the following store-verified keys:

| Key                     | Type      | Description                                                                     |
| ----------------------- | --------- | ------------------------------------------------------------------------------- |
| `transactionId`         | `String`  | App Store / Play Store transaction identifier                                   |
| `productId`             | `String`  | Purchased product identifier                                                    |
| `purchaseDate`          | `String`  | ISO-8601 date of the purchase                                                   |
| `originalTransactionId` | `String?` | Original transaction ID for renewals; equals `transactionId` for first purchase |
| `environment`           | `String`  | `Production` or `Sandbox`                                                       |
| `store`                 | `String`  | `app_store` or `play_store`                                                     |
| `currency`              | `String?` | ISO-4217 currency code (e.g., `USD`) when known                                 |
| `price`                 | `double?` | Numeric price paid (in `currency` units)                                        |

```dart theme={null}
// Inside onPurchaseCompleted:
final txnId = transaction['transactionId'] as String;
final originalTxnId = transaction['originalTransactionId'] as String?;
final isRenewal = originalTxnId != null && originalTxnId != txnId;
```

<Note>
  The `offerToken` parameter on `purchase(...)` is optional. On Android, pass the offer token from `ProductInfo.offerToken` to select a specific subscription offer. On iOS, this parameter is ignored. For Apple promotional offers (iOS), the SDK auto-applies any offer associated with the product in App Store Connect.
</Note>

<Info>
  Server-side receipt verification is performed automatically by the SDK. You do not need to send receipts to your own server for validation.
</Info>

## Restore Purchases

Restore previously purchased products (e.g., after reinstalling the app or switching devices):

```dart theme={null}
final entitlements = await AppDNA.billing.restorePurchases();

for (final ent in entitlements) {
  print("Restored: ${ent.productId} (${ent.status})");
}
```

Returns `List<Entitlement>`.

<Info>
  Apple requires that all apps with in-app purchases include a restore mechanism. Call `restorePurchases()` when the user taps a "Restore Purchases" button.
</Info>

## Check Active Subscription

Check whether the user has an active subscription:

```dart theme={null}
final active = await AppDNA.billing.hasActiveSubscription();

if (active) {
  // Unlock premium features
}
```

Returns `bool`.

## Get Entitlements

Retrieve all current entitlements for the user:

```dart theme={null}
final ents = await AppDNA.billing.getEntitlements();

for (final ent in ents) {
  print("${ent.productId}: ${ent.status}, trial: ${ent.isTrial}");
}
```

Returns `List<Entitlement>`.

### Entitlement

| Property    | Type      | Description                                      |
| ----------- | --------- | ------------------------------------------------ |
| `productId` | `String`  | The product identifier                           |
| `store`     | `String`  | The store that granted the entitlement           |
| `status`    | `String`  | Entitlement status (e.g., "active", "expired")   |
| `expiresAt` | `String?` | ISO 8601 expiration date, if applicable          |
| `isTrial`   | `bool`    | Whether the entitlement is from a free trial     |
| `offerType` | `String?` | The type of offer applied (e.g., "introductory") |

## Real-Time Entitlement Changes

### Stream

Listen for entitlement changes in real time via the `com.appdna.sdk/entitlements` EventChannel:

```dart theme={null}
AppDNA.billing.onEntitlementsChanged.listen((List<Entitlement> entitlements) {
  print("Entitlements changed: ${entitlements.length} active");
  // Update UI to reflect new entitlement state
});
```

Returns `Stream<List<Entitlement>>`.

### Callback

Alternatively, register a callback for entitlement changes:

```dart theme={null}
AppDNA.billing.onEntitlementsChangedCallback((List<Entitlement> entitlements) {
  print("Entitlements updated");
});
```

## AppDNABillingDelegate

Implement the `AppDNABillingDelegate` abstract class for fine-grained billing lifecycle callbacks:

```dart theme={null}
abstract class AppDNABillingDelegate {
  void onPurchaseCompleted(String productId, Map<String, dynamic> transaction);
  void onPurchaseFailed(String productId, Object error);
  void onEntitlementsChanged(List<Map<String, dynamic>> entitlements);
  void onRestoreCompleted(List<String> restoredProductIds);
  void onBillingUnavailable() {}
}
```

The 5 methods semantics:

* `onPurchaseCompleted(productId, transaction)` — fires after the store completes a purchase (paywall, direct `AppDNA.billing.purchase(...)`, or transaction queue retry). The `transaction` map shape is documented above.
* `onPurchaseFailed(productId, error)` — fires on store-side failure (user cancel, payment declined, network). The `error` is a typed `Object` (usually `Map<String, dynamic>` from native with `message` + `type` keys, or a raw `String`).
* `onEntitlementsChanged(entitlements)` — fires whenever the active entitlement set changes (purchase, restore, renewal, expiry, refund). Delivers a list of entitlement maps, each shaped like an `Entitlement` (`productId` / `store` / `status` / `expiresAt` / `isTrial` / `offerType`) — parse each with `Entitlement.fromMap`. For strongly-typed `Entitlement` objects you can also call `AppDNA.billing.getEntitlements()` from inside the callback, or subscribe to the `AppDNA.billing.onEntitlementsChanged` stream instead.
* `onRestoreCompleted(restoredProductIds)` — fires when a direct `AppDNA.billing.restorePurchases()` call resolves. Empty list means no prior purchases on file.
* `onBillingUnavailable()` — fires when billing is permanently unavailable (Play Services missing or broken). **Android-only** — it never fires on iOS. Hide paywalls / disable purchase UI when it fires.

**Overlap with `AppDNAPaywallDelegate`.** When a purchase originates from a paywall, the paywall delegate fires `onPaywallPurchaseStarted` / `onPaywallPurchaseCompleted` / `onPaywallPurchaseFailed`. The billing delegate ALSO fires `onPurchaseCompleted` / `onPurchaseFailed` for that same purchase — they're independent observers. Register both if you want paywall-specific UI (banners, confetti) AND a global purchase log; register only the billing delegate if you only care about the eventual state.

### Example Implementation

```dart theme={null}
class MyBillingHandler extends AppDNABillingDelegate {
  @override
  void onPurchaseCompleted(String productId, Map<String, dynamic> transaction) {
    print("Purchased: $productId");
    // Unlock premium features
  }

  @override
  void onPurchaseFailed(String productId, Object error) {
    // `error` is a typed Object -- usually a Map<String, dynamic> from native
    // ({ "message": "...", "type": "..." }) or a raw String. Stringify when displaying.
    print("Purchase failed: $productId -- $error");
    // Show error message to user
  }

  @override
  void onEntitlementsChanged(List<Map<String, dynamic>> entitlements) {
    // Each entry is an Entitlement-shaped map: productId/store/status/expiresAt/isTrial/offerType.
    final productIds = entitlements.map((e) => e["productId"]).join(', ');
    print("Entitlement product IDs: $productIds");
    // For strongly-typed entitlement objects, parse each map:
    //   final ents = entitlements.map(Entitlement.fromMap).toList();
  }

  @override
  void onRestoreCompleted(List<String> restoredProductIds) {
    print("Restored ${restoredProductIds.length} products");
  }

  @override
  void onBillingUnavailable() {
    // Android-only: billing permanently unavailable (Play Services missing/broken).
    print("Billing unavailable -- hiding purchase UI");
  }
}
```

## Full Example

```dart theme={null}
import 'package:appdna_sdk/appdna_sdk.dart';
import 'package:flutter/material.dart';

class SubscriptionPage extends StatefulWidget {
  const SubscriptionPage({super.key});

  @override
  State<SubscriptionPage> createState() => _SubscriptionPageState();
}

class _SubscriptionPageState extends State<SubscriptionPage> {
  List<ProductInfo> _products = [];
  bool _isSubscribed = false;

  @override
  void initState() {
    super.initState();
    _loadProducts();
    _checkSubscription();

    // Listen for entitlement changes
    AppDNA.billing.onEntitlementsChanged.listen((entitlements) {
      setState(() {
        _isSubscribed = entitlements.any((e) => e.status == "active");
      });
    });
  }

  Future<void> _loadProducts() async {
    final products = await AppDNA.billing.getProducts([
      "premium_monthly",
      "premium_yearly",
    ]);
    setState(() => _products = products);
  }

  Future<void> _checkSubscription() async {
    final active = await AppDNA.billing.hasActiveSubscription();
    setState(() => _isSubscribed = active);
  }

  Future<void> _purchase(ProductInfo product) async {
    final result = await AppDNA.billing.purchase(
      product.id,
      offerToken: product.offerToken,
    );
    if (result.status == 'purchased') {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text("Purchase successful!")),
      );
    }
  }

  Future<void> _restore() async {
    final restored = await AppDNA.billing.restorePurchases();
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text("Restored ${restored.length} purchases")),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Subscription"),
        actions: [
          TextButton(
            onPressed: _restore,
            child: const Text("Restore"),
          ),
        ],
      ),
      body: ListView(
        children: _products.map((product) {
          return ListTile(
            title: Text(product.name),
            subtitle: Text(product.description),
            trailing: Text(product.displayPrice),
            onTap: () => _purchase(product),
          );
        }).toList(),
      ),
    );
  }
}
```

<Warning>
  Always test purchases using sandbox/test accounts. On iOS, use StoreKit Testing in Xcode or a Sandbox Apple ID. On Android, use license testing accounts configured in the Google Play Console.
</Warning>

## Auto-Tracked Events

The SDK automatically tracks the following billing events. You don't need to call `AppDNA.track(...)` for any of them.

| Event                | Trigger                                                                                                      |
| -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `purchase_started`   | Customer taps the purchase CTA. Properties include `productId`, `paywallId` (when triggered from a paywall). |
| `purchase_completed` | Purchase succeeds. Properties include `productId`, `transactionId`, `price`, `currency`.                     |
| `purchase_failed`    | Purchase fails. Properties include `productId`, `error`.                                                     |
| `purchase_canceled`  | Customer dismisses the platform purchase sheet without completing.                                           |
| `purchase_pending`   | A purchase is waiting for approval (e.g., Ask to Buy, deferred payment).                                     |
| `purchase_restored`  | A previously purchased product is restored.                                                                  |

Android-only events (fired by Play Billing's subscription lifecycle):

| Event                         | Trigger                                                                       |
| ----------------------------- | ----------------------------------------------------------------------------- |
| `billing_purchase_requested`  | A purchase call has been queued and is about to launch the Play Billing flow. |
| `subscription_renewed`        | An auto-renewing subscription successfully renews.                            |
| `subscription_canceled`       | A subscription is cancelled (access may continue until period end).           |
| `subscription_renewal_failed` | A renewal attempt fails (payment issue).                                      |

These events flow into the AppDNA Console exactly like any custom event you track yourself, and feed retention / monetization dashboards out of the box.
