ackbiz_billing 0.13.2
ackbiz_billing: ^0.13.2 copied to clipboard
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',
];