> ## 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.

# React Native Billing

> In-app purchases, entitlements, and restore

<Info>
  **Supported on:** iOS SDK `1.0.70+` · Android SDK `1.0.42+` · React Native SDK `1.0.7+` (New Architecture only)
</Info>

The AppDNA React Native 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.

## Configuration

Set the billing provider when configuring the SDK:

```typescript theme={null}
import { AppDNA } from '@appdna-ai/react-native-sdk';

await AppDNA.configure('adn_live_xxx', 'production', { billingProvider: 'storeKit2' });
```

### AppDNABillingProvider Options

| Value          | Description                                                |
| -------------- | ---------------------------------------------------------- |
| `'storeKit2'`  | Native StoreKit 2 (iOS) / Play Billing (Android) — default |
| `'revenueCat'` | RevenueCat integration                                     |
| `'none'`       | Disable the billing module entirely                        |

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

## Billing Module

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

```typescript theme={null}
const billing = AppDNA.billing;
```

## Get Products

Fetch product information for one or more product identifiers:

```typescript theme={null}
const products = await AppDNA.billing.getProducts(['premium_monthly']);

for (const product of products) {
  console.log(`${product.name}: ${product.displayPrice}`);
}
```

Returns `ProductInfo[]`.

### ProductInfo

Platform-specific keys are **omitted, never faked**: an absent key means the platform has no concept of it.

| Property         | Type       | Description                                                              |
| ---------------- | ---------- | ------------------------------------------------------------------------ |
| `id`             | `string`   | Product identifier                                                       |
| `name`           | `string`   | Localized product name                                                   |
| `description`    | `string`   | Localized product description                                            |
| `displayPrice`   | `string`   | Localized, store-formatted price (e.g., "\$9.99") — the string to render |
| `priceMicros`    | `number`   | Price × 1,000,000, as an integer (\$9.99 → `9990000`)                    |
| `currencyCode`   | `string?`  | ISO-4217 currency code (e.g., `USD`). **Android only**                   |
| `isSubscription` | `boolean?` | **iOS only**                                                             |
| `offerToken`     | `string?`  | Base-plan offer token to pass back to `purchase(...)`. **Android only**  |

<Note>
  There is no numeric `price` field. iOS's native price is a `Decimal`, which cannot cross the bridge without loss, so both platforms send integer `priceMicros`. Render `displayPrice`, or divide `priceMicros` by 1,000,000 when you need the number.
</Note>

## Purchase a Product

Initiate a purchase for a product:

```typescript theme={null}
import type { AppDNAPurchaseErrorCode } from '@appdna-ai/react-native-sdk';

declare function showAwaitingApproval(): void;
declare function showRetry(): void;
declare function showPurchaseFailed(): void;

try {
  const transaction = await AppDNA.billing.purchase('premium_monthly', 'offer-xxx');
  // Resolved means PURCHASED. There is no status to check.
  console.log(`Purchased ${transaction.productId} (${transaction.transactionId})`);
} catch (error) {
  // A user cancellation, a pending (deferred / ask-to-buy) purchase, and a store failure all REJECT.
  // Branch on the CODE — never on the message, which is localized.
  const code = (error as { code?: AppDNAPurchaseErrorCode }).code;
  switch (code) {
    case 'userCancelled':
      break;                                   // the user tapped Cancel. Say nothing.
    case 'pending':
      showAwaitingApproval();                  // ask-to-buy / SCA. Entitlements arrive later, if approved.
      break;
    case 'networkError':
    case 'serverError':
      showRetry();
      break;
    default:
      showPurchaseFailed();
  }
}
```

The `code` is identical on iOS and Android:

| `code`                 | Meaning                                                             |
| ---------------------- | ------------------------------------------------------------------- |
| `userCancelled`        | The user dismissed the store sheet. Almost always: show nothing.    |
| `pending`              | Deferred / ask-to-buy. Not complete; entitlements arrive later.     |
| `productNotFound`      | The product id is not in the store catalog. A configuration bug.    |
| `verificationFailed`   | Receipt verification failed server-side. Do **not** grant access.   |
| `networkError`         | The device could not reach the store or the verification endpoint.  |
| `serverError`          | The store or verification endpoint returned an error.               |
| `providerNotAvailable` | The configured provider (RevenueCat / Adapty) is not in the binary. |
| `unknown`              | Anything else — never force-fit into a category you would act on.   |

Returns a `TransactionInfo`.

### TransactionInfo

| Property        | Type     | Description                                                                                                                                                                           |
| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transactionId` | `string` | App Store / Play Store transaction identifier                                                                                                                                         |
| `productId`     | `string` | Purchased product identifier                                                                                                                                                          |
| `purchaseDate`  | `string` | Epoch milliseconds as a decimal string (e.g. `"1721044800000"`) on both platforms. Parse with `new Date(Number(tx.purchaseDate))` — `new Date(tx.purchaseDate)` gives `Invalid Date`. |
| `environment`   | `string` | `production` or `sandbox`                                                                                                                                                             |

The entitlement the purchase granted arrives on `getEntitlements()` / `onEntitlementsChanged`, which is where entitlement state has always lived.

### Transaction Object Keys

The `transaction` payload delivered to `onPurchaseCompleted` (and `AppDNAPaywallDelegate.onPaywallPurchaseCompleted`) is the same four keys, as a `Record<string, unknown>`:

```typescript theme={null}
const delegate: Pick<AppDNABillingDelegate, 'onPurchaseCompleted'> = {
  onPurchaseCompleted(productId, transaction) {
    const txnId = transaction.transactionId as string;
    const purchasedAt = new Date(Number(transaction.purchaseDate)); // epoch millis as a decimal string
    console.log(productId, txnId, purchasedAt);
  },
};
```

<Note>
  The `offerToken` argument 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):

```typescript theme={null}
const restoredProductIds = await AppDNA.billing.restorePurchases();

for (const productId of restoredProductIds) {
  console.log(`Restored: ${productId}`);
}

// For the entitlement objects themselves (status, expiry, trial):
const entitlements = await AppDNA.billing.getEntitlements();
```

Returns `string[]` — the restored **product IDs**, not entitlements.

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

```typescript theme={null}
const active = await AppDNA.billing.hasActiveSubscription();

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

Returns `boolean`.

## Get Entitlements

Retrieve all current entitlements for the user:

```typescript theme={null}
const ents = await AppDNA.billing.getEntitlements();

for (const ent of ents) {
  console.log(`${ent.productId}: ${ent.status}, trial: ${ent.isTrial}`);
}
```

Returns `Entitlement[]`.

### Entitlement

| Property     | Type       | Description                                                                                |
| ------------ | ---------- | ------------------------------------------------------------------------------------------ |
| `identifier` | `string`   | Both platforms — the entitlement identifier (Android aliases it to `productId`)            |
| `productId`  | `string`   | Both platforms — the product identifier                                                    |
| `isActive`   | `boolean`  | Both platforms — the cross-platform question to ask; Android derives it from `status`      |
| `expiresAt`  | `string?`  | ISO-8601 expiry. **Omitted** when there is no expiry — check `=== undefined`, never `null` |
| `store`      | `string?`  | **Android only** — the store that granted the entitlement                                  |
| `status`     | `string?`  | **Android only** — raw Play status (`active` \| `trialing` \| `grace_period` \| …)         |
| `isTrial`    | `boolean?` | **Android only** — whether the entitlement is from a free trial                            |
| `offerType`  | `string?`  | **Android only** — the offer applied; omitted when the entitlement carries no offer        |

## Real-Time Entitlement Changes

### Subscription callback

Listen for entitlement changes in real time. The first subscriber starts the native entitlement observer, so nothing is missed:

```typescript theme={null}
const unsubscribe = AppDNA.billing.onEntitlementsChanged((entitlements) => {
  console.log(`Entitlements changed: ${entitlements.length} active`);
  // Update UI to reflect new entitlement state
});

// Later, when you no longer need updates:
unsubscribe();
```

`onEntitlementsChanged` returns an unsubscribe function. Call it on component unmount to avoid leaks.

## AppDNABillingDelegate

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

```typescript theme={null}
interface AppDNABillingDelegate {
  onPurchaseCompleted(productId: string, transaction: Record<string, unknown>): void;
  /** `error` is a message STRING, not an `Error` object. */
  onPurchaseFailed(productId: string, error: unknown): void;
  /** Each entry is an Entitlement map — not a product-ID string. */
  onEntitlementsChanged(entitlements: Record<string, unknown>[]): void;
  onRestoreCompleted(restoredProductIds: string[]): void;
  /** Android only — Play Billing is permanently unavailable on this device. */
  onBillingUnavailable(): void;
}
```

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). **`error` is the platform's error message as a string**, delivered as `unknown` — it is not a JS `Error`, and it has no `.message` property. Narrow it (`String(error)`) before displaying. For a machine-readable reason code, use `AppDNAPaywallDelegate.onPaywallPurchaseFailed`, which also carries `errorType`.
* `onEntitlementsChanged(entitlements)` — fires whenever the active entitlement set changes (purchase, restore, renewal, expiry, refund). Delivers an array of **entitlement maps** (`productId`, `store`, `status`, `expiresAt`, `isTrial`, `offerType`) — not product-ID strings. For typed `Entitlement` objects, subscribe with `AppDNA.billing.onEntitlementsChanged(...)` instead, which parses them for you.
* `onRestoreCompleted(restoredProductIds)` — fires when a direct `AppDNA.billing.restorePurchases()` call resolves. Empty array means no prior purchases on file.
* `onBillingUnavailable()` — **Android only.** Play Services is missing or broken and billing will never work on this device. Never fires on iOS. Hide your purchase UI.

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

```typescript theme={null}
const billingHandler: AppDNABillingDelegate = {
  onPurchaseCompleted(productId, transaction) {
    console.log(`Purchased: ${productId}`);
    // Unlock premium features
  },

  onPurchaseFailed(productId, error) {
    // `error` is a message string delivered as `unknown` — there is no `.message`.
    console.log(`Purchase failed: ${productId} -- ${String(error)}`);
    // Show error message to user
  },

  onEntitlementsChanged(entitlements) {
    // Each entry is an entitlement MAP, not a product-ID string.
    console.log(`Entitlements: ${entitlements.map((e) => e.productId).join(', ')}`);
  },

  onBillingUnavailable() {
    // Android only: Play Services is missing/broken. Hide the purchase UI.
  },

  onRestoreCompleted(restoredProductIds) {
    console.log(`Restored ${restoredProductIds.length} products`);
  },
};

AppDNA.billing.setDelegate(billingHandler);
```

## Full Example

```tsx theme={null}
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList, Pressable, Button, Alert } from 'react-native';
import { AppDNA, ProductInfo } from '@appdna-ai/react-native-sdk';

export function SubscriptionPage() {
  const [products, setProducts] = useState<ProductInfo[]>([]);
  const [isSubscribed, setIsSubscribed] = useState(false);

  useEffect(() => {
    loadProducts();
    checkSubscription();

    const unsubscribe = AppDNA.billing.onEntitlementsChanged((entitlements) => {
      setIsSubscribed(entitlements.some((e) => e.status === 'active'));
    });

    return unsubscribe;
  }, []);

  async function loadProducts() {
    const result = await AppDNA.billing.getProducts(['premium_monthly', 'premium_yearly']);
    setProducts(result);
  }

  async function checkSubscription() {
    const active = await AppDNA.billing.hasActiveSubscription();
    setIsSubscribed(active);
  }

  async function purchase(product: ProductInfo) {
    try {
      await AppDNA.billing.purchase(product.id, product.offerToken);
      // Resolved means purchased; a cancellation or store error rejects.
      Alert.alert('Purchase successful!');
    } catch {
      // User cancelled, or the store failed. Nothing to unlock.
    }
  }

  async function restore() {
    const restored = await AppDNA.billing.restorePurchases();
    Alert.alert(`Restored ${restored.length} purchases`);
  }

  return (
    <View style={{ flex: 1 }}>
      <View style={{ padding: 16, flexDirection: 'row', justifyContent: 'flex-end' }}>
        <Button title="Restore" onPress={restore} />
      </View>
      <FlatList
        data={products}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <Pressable
            onPress={() => purchase(item)}
            style={{ padding: 16, borderBottomWidth: 1, borderColor: '#eee' }}
          >
            <Text style={{ fontWeight: 'bold' }}>{item.name}</Text>
            <Text>{item.description}</Text>
            <Text>{item.displayPrice}</Text>
          </Pressable>
        )}
      />
    </View>
  );
}
```

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