ackbiz_billing 0.13.2 copy "ackbiz_billing: ^0.13.2" to clipboard
ackbiz_billing: ^0.13.2 copied to clipboard

PlatformAndroid

Shared billing for every ACKBiz app: Google Play, User Choice Billing, and the ACKBiz alternative rail, behind one purchase call.

example/lib/main.dart

// A DEVICE TEST HARNESS, not a paywall and not a design reference.
//
// It exists because the package can be entirely correct and still fail on a
// handset in ways no unit test reaches: a wrong product id, a salt that does
// not match the backend's, a plan left inactive, User Choice not enabled for
// the account. Every one of those looks like "the button does nothing".
//
// So this draws each intermediate value on the screen -- the device id, what
// RevenueCat says, what the backend says, what Play returned -- because the
// useful question after a failed test is WHICH LINK broke, and a paywall is
// designed to hide exactly that.
//
// It still compiles the plugin, which was this app's original job: `flutter
// analyze` never touches Kotlin and `pub publish` validates metadata rather
// than whether a plugin links.
//
// NO CREDENTIALS LIVE HERE. Everything arrives through --dart-define at build
// time; see example/README.md.
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:convert';
import 'dart:math';

import 'package:ackbiz_billing/ackbiz_billing.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:purchases_flutter/purchases_flutter.dart';

const _baseUrl = String.fromEnvironment('ACK_BASE_URL');
const _appKey = String.fromEnvironment('ACK_APP_KEY');
const _salt = String.fromEnvironment('ACK_DEVICE_SALT');
const _packageName = String.fromEnvironment(
  'ACK_PACKAGE_NAME',
  defaultValue: 'com.ackbiz.khetihisab',
);
const _rcKey = String.fromEnvironment('RC_PUBLIC_KEY');
const _entitlementId = String.fromEnvironment(
  'ACK_ENTITLEMENT_ID',
  defaultValue: 'pro',
);

void main() => runApp(const HarnessApp());

class HarnessApp extends StatelessWidget {
  const HarnessApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'ackbiz_billing harness',
        theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
        home: const _Harness(),
      );
}

class _Harness extends StatefulWidget {
  const _Harness();

  @override
  State<_Harness> createState() => _HarnessState();
}

class _HarnessState extends State<_Harness> {
  final _transport = AndroidBillingTransport();
  final _log = <String>[];

  // One store, shared: the API writes the token and the registrar reads it.
  static const AckTokenStore _tokens = SecureTokenStore();
  late final AckBizApi _api;
  BillingRouter? _router;
  StreamSubscription<AckBillingEvent>? _events;

  String? _deviceId;
  bool _ready = false;
  bool _busy = false;
  bool? _userChoiceAvailable;
  String? _playCountry;

  // Typed rather than compiled in, so both plans are testable from one build.
  final _planCode = TextEditingController(text: 'pro_monthly');
  final _productId = TextEditingController(text: 'kheti_hisab_pro_monthly');
  final _basePlanId = TextEditingController(text: 'kheti-hisab-pro-monthly');
  bool _isSubscription = true;

  bool? _rcSaysPro;
  bool? _backendSaysPro;

  @override
  void initState() {
    super.initState();
    // One store, shared: the API writes the token and the registrar reads it
    // to decide whether registration is needed at all.
    _api = AckBizApi(
      baseUrl: Uri.parse(_baseUrl.isEmpty ? 'https://ackbiz.com' : _baseUrl),
      appKey: _appKey,
      packageName: _packageName,
      sender: _Sender(),
      tokens: _tokens,
    );
  }

  @override
  void dispose() {
    _events?.cancel();
    _transport.dispose();
    _planCode.dispose();
    _productId.dispose();
    _basePlanId.dispose();
    super.dispose();
  }

  void _say(String line) {
    debugPrint('[harness] $line');
    if (mounted) setState(() => _log.insert(0, line));
  }

  // --- setup ---------------------------------------------------------------

  Future<void> _start() async {
    setState(() => _busy = true);
    try {
      final identity = DeviceIdentity(
        read: _transport.rawAndroidId,
        salt: _salt,
      );
      final deviceId = await identity.get();
      _deviceId = deviceId;
      _api.deviceId = deviceId;
      _say('device id ${deviceId.substring(0, 12)}...');

      /*
       * REGISTER ONCE PER INSTALL. `device/register` is the only endpoint that
       * can touch a seat, so calling it on every launch spends the customer's
       * seat-change allowance on nothing -- and COOLDOWN_BLOCKED then refuses
       * the change they actually wanted.
       *
       * `ensureRegistered` reads the stored token first and does not reach the
       * network when there is one. The device info is a callback because
       * building it costs platform calls a relaunch should not pay for.
       */
      final registration = await AckDeviceRegistrar(
        api: _api,
        tokens: _tokens,
      ).ensureRegistered(
        () async => AckDeviceInfo(
          deviceId: deviceId,
          platform: Platform.isIOS ? 'ios' : 'android',
        ),
      );
      _say('registration=${registration.name}');

      /*
       * RevenueCat is configured with the DEVICE ID as the app user id, and
       * this is not a detail to improvise. The backend grants against
       * `rcAppUserIdFor(deviceId)`, which IS the device id (seats/claim.ts).
       * Configure RevenueCat with anything else -- an anonymous id, an account
       * id -- and the grant lands on a customer this app never asks about, so
       * a perfectly good purchase reads as no entitlement, forever.
       */
      if (_rcKey.isNotEmpty) {
        await Purchases.configure(
          PurchasesConfiguration(_rcKey)..appUserID = deviceId,
        );
        _say('revenuecat configured as this device');
      } else {
        _say('no RC_PUBLIC_KEY: skipping RevenueCat, backend only');
      }

      final available = await _transport.connect();
      final country = await _transport.countryCode();
      _userChoiceAvailable = available;
      _playCountry = country;
      _say('play connected, user choice: $available, country: $country');

      _events = _transport.events.listen(_onBillingEvent);

      setState(() => _ready = true);
      await _refreshEntitlement();
    } catch (error) {
      _say('SETUP FAILED: $error');
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  /*
   * The transport emits; the router is told. The package deliberately does not
   * subscribe on your behalf -- an app may want to log or count these -- so
   * this wiring belongs to the app, and every branch has to be present.
   *
   * `AckBillingFailed` is the one that is easy to leave out, and leaving it
   * out is a paywall that spins forever on ITEM_ALREADY_OWNED.
   */
  void _onBillingEvent(AckBillingEvent event) {
    final router = _router;
    switch (event) {
      case AckPlayPurchased(:final purchaseToken):
        _say('play purchase, verifying with the backend');
        unawaited(
          router?.onPlayPurchase(purchaseToken: purchaseToken) ??
              Future<void>.value(),
        );
      case AckAlternativeChosen(:final externalTransactionToken):
        _say('user chose the ACKBiz rail');
        unawaited(
          router?.onAlternativeSelected(
                externalTransactionToken: externalTransactionToken,
              ) ??
              Future<void>.value(),
        );
      case AckBillingCancelled():
        _say('cancelled by the user');
        router?.onUserCancelled();
      case AckBillingFailed(:final message):
        _say('play refused: $message');
        router?.onFailed(message);
    }
  }

  // --- the test itself -----------------------------------------------------

  Future<void> _buy({required bool useUcb}) async {
    setState(() => _busy = true);

    final router = BillingRouter(
      transport: _transport,
      alternative: RazorpayAlternativeFlow(api: _api, askState: _askState),
      // One verifier for every plan the app sells: the plan arrives per call.
      verifier: ApiPlayVerifier(api: _api),
      newIdempotencyKey: _uuidV4,
    );
    _router = router;

    /*
     * The plan is one value from here on. A lifetime plan is a one-time
     * product with no base plan, and verifying it as a subscription asks
     * Google the wrong question -- which is why `isSubscription` travels with
     * the plan rather than being set once on the verifier.
     */
    final plan = AckPlan(
      planCode: _planCode.text.trim(),
      productId: _productId.text.trim(),
      basePlanId:
          _basePlanId.text.trim().isEmpty ? null : _basePlanId.text.trim(),
      isSubscription: _isSubscription,
    );

    _say('buying ${plan.planCode} (ucb: $useUcb)...');
    final result = await router.purchase(plan: plan, useUcb: useUcb);

    final detail = result.message == null ? '' : ' - ${result.message}';
    _say('OUTCOME: ${result.outcome.name}$detail');
    if (result.outcome == AckPurchaseOutcome.seatChoiceRequired) {
      _say('seats offered: ${result.seats?.length ?? 0} (paid, needs a seat)');
    }

    if (mounted) setState(() => _busy = false);
    await _refreshEntitlement();
  }

  Future<void> _refreshEntitlement() async {
    /*
     * Both sources, separately, rather than `Entitlements.isPro()`.
     *
     * isPro() is what a real app calls: it takes the first source that says
     * yes. That is the right behaviour and the wrong diagnostic. The question
     * after a test purchase is whether the backend granted AND whether
     * RevenueCat delivered, and one boolean cannot answer both -- a backend
     * `true` with a RevenueCat `false` is the rc_grant job failing, which is
     * precisely what /admin/queue exists to show.
     */
    try {
      _backendSaysPro = await _api.isEntitled(entitlementId: _entitlementId);
    } catch (error) {
      _say('backend entitlement check failed: $error');
      _backendSaysPro = null;
    }

    if (_rcKey.isNotEmpty) {
      try {
        final info = await Purchases.getCustomerInfo();
        _rcSaysPro = info.entitlements.active.containsKey(_entitlementId);
      } catch (error) {
        _say('revenuecat check failed: $error');
        _rcSaysPro = null;
      }
    }

    if (mounted) setState(() {});
  }

  Future<String?> _askState() => showDialog<String>(
        context: context,
        builder: (context) => SimpleDialog(
          title: const Text('Which state? (GST place of supply)'),
          children: [
            for (final state in _states)
              SimpleDialogOption(
                onPressed: () => Navigator.pop(context, state),
                child: Text(state),
              ),
          ],
        ),
      );

  // --- ui ------------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    final missing = <String>[
      if (_baseUrl.isEmpty) 'ACK_BASE_URL',
      if (_appKey.isEmpty) 'ACK_APP_KEY',
      if (_salt.isEmpty) 'ACK_DEVICE_SALT',
    ];

    return Scaffold(
      appBar: AppBar(title: const Text('ackbiz_billing harness')),
      body: missing.isNotEmpty
          ? _MissingConfig(missing: missing)
          : ListView(
              padding: const EdgeInsets.all(16),
              children: [
                _Row('backend', _baseUrl),
                _Row('package', _packageName),
                _Row('device id', _deviceId ?? '-'),
                _Row('user choice', '${_userChoiceAvailable ?? '-'}'),
                _Row('play country', _playCountry ?? '-'),
                const Divider(height: 24),
                _Row('backend says pro', '${_backendSaysPro ?? '-'}'),
                _Row(
                  'revenuecat says pro',
                  _rcKey.isEmpty ? 'not configured' : '${_rcSaysPro ?? '-'}',
                ),
                const Divider(height: 24),
                if (!_ready)
                  FilledButton(
                    onPressed: _busy ? null : _start,
                    child: Text(_busy ? 'Starting...' : 'Register and connect'),
                  )
                else ...[
                  TextField(
                    controller: _planCode,
                    decoration: const InputDecoration(
                      labelText: 'plan code (ours)',
                    ),
                  ),
                  TextField(
                    controller: _productId,
                    decoration: const InputDecoration(
                      labelText: 'google product id',
                    ),
                  ),
                  TextField(
                    controller: _basePlanId,
                    decoration: const InputDecoration(
                      labelText: 'google base plan id (subscriptions only)',
                    ),
                  ),
                  SwitchListTile(
                    value: _isSubscription,
                    onChanged: (on) => setState(() => _isSubscription = on),
                    title: const Text('subscription'),
                    subtitle: const Text('off for a lifetime one-time product'),
                  ),
                  const SizedBox(height: 8),
                  FilledButton(
                    onPressed: _busy ? null : () => _buy(useUcb: true),
                    child: const Text('Buy - User Choice sheet'),
                  ),
                  const SizedBox(height: 8),
                  OutlinedButton(
                    onPressed: _busy ? null : () => _buy(useUcb: false),
                    child: const Text('Buy - Google Play only'),
                  ),
                  const SizedBox(height: 8),
                  TextButton(
                    onPressed: _busy ? null : _refreshEntitlement,
                    child: const Text('Re-check entitlement'),
                  ),
                ],
                const Divider(height: 24),
                const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)),
                for (final line in _log)
                  Padding(
                    padding: const EdgeInsets.symmetric(vertical: 2),
                    child: Text(line, style: const TextStyle(fontSize: 12)),
                  ),
              ],
            ),
    );
  }
}

class _MissingConfig extends StatelessWidget {
  const _MissingConfig({required this.missing});

  final List<String> missing;

  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'Built without configuration',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 12),
            Text('Missing: ${missing.join(', ')}'),
            const SizedBox(height: 12),
            const Text(
              'These are --dart-define values, not defaults. See '
              'example/README.md for the build command.\n\n'
              "ACK_DEVICE_SALT must equal the backend's DEVICE_ID_SALT "
              'exactly. If it does not, nothing errors - every device simply '
              'looks new and no purchase is ever found.',
            ),
          ],
        ),
      );
}

class _Row extends StatelessWidget {
  const _Row(this.label, this.value);

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.symmetric(vertical: 3),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            SizedBox(
              width: 150,
              child: Text(label, style: const TextStyle(color: Colors.grey)),
            ),
            Expanded(
              child:
                  Text(value, style: const TextStyle(fontFamily: 'monospace')),
            ),
          ],
        ),
      );
}

/// The adapter from the package's transport seam to a real HTTP client. This
/// is the snippet an app copies, so it is written the way an app would.
class _Sender implements AckHttpSender {
  final _client = http.Client();

  @override
  Future<AckResponse> send(AckRequest request) async {
    final sent = await _client
        .send(
          http.Request(request.method, request.url)
            ..headers.addAll(request.headers)
            ..body = request.body ?? '',
        )
        // Reads ask for less; the ceiling holds for everything else.
        .timeout(request.timeout ?? const Duration(seconds: 30));

    return AckResponse(
      statusCode: sent.statusCode,
      body: utf8.decode(await sent.stream.toBytes()),
      // Already lower-cased by http; the renewed-token header is read here.
      headers: sent.headers,
    );
  }
}

/// A v4 UUID without a dependency. `Random.secure` because an idempotency key
/// a second attempt could guess is not an idempotency key.
String _uuidV4() {
  final random = Random.secure();
  final bytes = List<int>.generate(16, (_) => random.nextInt(256));
  bytes[6] = (bytes[6] & 0x0f) | 0x40;
  bytes[8] = (bytes[8] & 0x3f) | 0x80;

  final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
  return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
      '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}

/*
 * The codes the backend accepts, which are FULL UPPERCASE NAMES rather than
 * the two-letter abbreviations everyone reaches for first. `indian_states.code`
 * holds 'GUJARAT', not 'GJ', and a mismatch is INVALID_STATE at checkout.
 *
 * Shortened here to the ones a tester is plausibly in; the backend holds all 36.
 */
const _states = [
  'GUJARAT',
  'MAHARASHTRA',
  'DELHI',
  'KARNATAKA',
  'TAMIL NADU',
  'UTTAR PRADESH',
  'RAJASTHAN',
  'WEST BENGAL',
];
0
likes
150
points
813
downloads

Documentation

API reference

Publisher

verified publisherackplus.com

Weekly Downloads

Shared billing for every ACKBiz app: Google Play, User Choice Billing, and the ACKBiz alternative rail, behind one purchase call.

Repository (GitHub)
View/report issues

Topics

#billing #in-app-purchase #subscriptions #google-play #revenuecat

License

MIT (license)

Dependencies

crypto, flutter, flutter_secure_storage, http, razorpay_flutter, shared_preferences

More

Packages that depend on ackbiz_billing

Packages that implement ackbiz_billing