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

# EncorePurchaseController

> The controller your app registers so Encore offers purchase through your billing code

Every purchase triggered by an Encore offer runs through an `EncorePurchaseController` your app registers with [`configure()`](./configure). The SDK has no built-in billing and never runs purchase code you did not write: your controller owns product lookup, receipt validation, restore handling, and entitlement bookkeeping, exactly as it does for every other purchase in your app.

Encore controls *when* a purchase happens; you own *how*.

## Definition

```dart theme={null}
abstract class EncorePurchaseController {
  // Called when a user accepts an offer that carries an in-app purchase.
  // Awaited: the offer flow waits for your answer. Throw for a real failure.
  Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request);
}

enum EncorePurchaseResult {
  purchased, // the purchase completed
  cancelled, // the user backed out
  pending,   // the store deferred the purchase; it may complete later
}
```

Each native plugin owns the native conformance and forwards every request over the method channel, waiting until your Dart code answers.

* **Throwing records a failure.** A throw lands on the result as `EncorePublisherOutcome.failed`, and the flow continues. Map your billing layer's "user cancelled" error to `cancelled` rather than throwing it.
* **An unrecognized answer is a failure**, never `purchased`, because a phantom success would grant access nobody paid for.
* **The controller survives [`reset()`](./reset).** It is app-level wiring, not user state.

<Warning>
  **`pending` is a first-class result, not a soft failure.** It means Ask to Buy (parental approval) on iOS, or SCA or a pending Play transaction on Android: the user has neither bought nor abandoned, and the store's eventual webhook is the source of truth. Returning `cancelled` for a deferred purchase records a sale still in flight as a decline. Route your billing library's deferred branch to `pending`.
</Warning>

## EncorePurchaseRequest

What your controller receives when Encore needs a purchase.

```dart theme={null}
class EncorePurchaseRequest {
  final String productId;      // store product to purchase
  final String? placementId;   // placement that triggered it
  final String? promoOfferId;  // App Store promotional offer (iOS)
  final String? basePlanId;    // Play base plan to select (Android)
}
```

| Property       | Type      | Description                                                                                                                            |
| -------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `productId`    | `String`  | The store product identifier to purchase, for example `com.app.monthly_premium`                                                        |
| `placementId`  | `String?` | Which placement triggered this purchase. On iOS the SDK generates an ID when the placement had none                                    |
| `promoOfferId` | `String?` | App Store Connect promotional offer identifier. iOS only; `null` for standard purchases and on Android                                 |
| `basePlanId`   | `String?` | Google Play subscription base plan ID, when the product exposes several. Android only; `null` on iOS and for single-base-plan products |

<Note>
  Android's native controller also hands over the foreground `Activity`. The plugin keeps it on the native side, since it is valid only for the duration of the call, so one Dart contract covers both platforms.
</Note>

## Implementations

<Tabs>
  <Tab title="RevenueCat">
    ```dart theme={null}
    import 'package:encore_flutter/encore_flutter.dart';
    import 'package:flutter/services.dart';
    import 'package:purchases_flutter/purchases_flutter.dart';

    class AppPurchases implements EncorePurchaseController {
      @override
      Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request) async {
        // Buys the product's default option. If your Play products expose several
        // base plans, purchase the subscription option matching request.basePlanId.
        final products = await Purchases.getProducts([request.productId]);
        if (products.isEmpty) {
          throw StateError('Unknown product ${request.productId}');
        }
        try {
          await Purchases.purchaseStoreProduct(products.first);
          return EncorePurchaseResult.purchased;
        } on PlatformException catch (e) {
          final code = PurchasesErrorHelper.getErrorCode(e);
          if (code == PurchasesErrorCode.purchaseCancelledError) {
            return EncorePurchaseResult.cancelled;
          }
          if (code == PurchasesErrorCode.paymentPendingError) {
            return EncorePurchaseResult.pending;
          }
          rethrow; // recorded as EncorePublisherOutcome.failed
        }
      }
    }
    ```
  </Tab>

  <Tab title="Custom">
    ```dart theme={null}
    import 'package:encore_flutter/encore_flutter.dart';

    class AppPurchases implements EncorePurchaseController {
      AppPurchases(this.billing);

      final BillingService billing; // your app's existing billing layer

      @override
      Future<EncorePurchaseResult> purchase(EncorePurchaseRequest request) async {
        final outcome = await billing.purchase(
          request.productId,
          basePlanId: request.basePlanId,     // Android, optional
          promoOfferId: request.promoOfferId, // iOS, optional
        );
        if (outcome.userCancelled) return EncorePurchaseResult.cancelled;
        if (outcome.deferred) return EncorePurchaseResult.pending;
        return EncorePurchaseResult.purchased;
      }
    }
    ```
  </Tab>
</Tabs>

## Related

* [configure()](./configure): accepts the controller, and defines what happens when none is registered
* [EncorePresentationResult](./presentation-result): where the controller's answer lands as `publisher`
