ackbiz_billing 0.7.0
ackbiz_billing: ^0.7.0 copied to clipboard
Shared billing for every ACKBiz app: Google Play, User Choice Billing, and the ACKBiz alternative rail, behind one purchase call.
ackbiz_billing #
Shared billing for every ACKBiz app. One purchase() call covers Google Play,
User Choice Billing, and the ACKBiz alternative rail; the app never branches on
which one was used.
Specification: docs/billing/12-mobile-flutter.md in the ackbiz web repo. That
document is the source of truth — this package implements it and does not
restate it.
Status #
Core, transport and platform layer are written. The Android module compiles
alongside RevenueCat — example/ exists to prove it, because flutter analyze never touches Kotlin, pub publish validates metadata rather than
whether a plugin links, and a package that builds on its own can still be
unresolvable in every app that would use it.
Nothing here has taken a payment yet. Compiling proves linkage; it proves
nothing about whether a sheet opens, a token verifies, or a grant lands. That
needs a device, license testers and a Play Console enrolment — TASK-I01 in
docs/billing/20-build-plan.md.
| Piece | State |
|---|---|
BillingRouter — one completer, one idempotency key |
done, tested |
outcomeForStatus — status to outcome, incl. F-44 |
done, tested |
DeviceIdentity — salted hash of ANDROID_ID |
done, tested |
Entitlements — three sources plus an offline cache |
done, tested |
AckBizApi — all eight endpoints |
done, tested against a fake sender |
AndroidBillingTransport + the plugin |
compiles, never run on a device |
RazorpayAlternativeFlow |
compiles, never taken a payment |
ApiPlayVerifier, SecureTokenStore, PreferencesEntitlementCache |
done |
| Paywall UI, seat chooser, state picker | the app's, not this package's |
Putting it together #
The package draws nothing and decides nothing about your UI. Four things are yours to supply: an HTTP client, the state picker, RevenueCat, and the paywall.
// 1. HTTP. Your app already has a client; this is the adapter.
class _Sender implements AckHttpSender {
final _client = http.Client();
@override
Future<AckResponse> send(AckRequest request) async {
final response = await _client
.send(http.Request(request.method, request.url)
..headers.addAll(request.headers)
..body = request.body ?? '')
.timeout(const Duration(seconds: 30));
final body = await response.stream.bytesToString();
return AckResponse(
statusCode: response.statusCode,
body: body,
// Lower-cased: the renewed-token header is read from here.
headers: response.headers,
);
}
}
// 2. The API, and the device it speaks for.
final api = AckBizApi(
baseUrl: Uri.parse('https://ackbiz.com'),
appKey: const String.fromEnvironment('ACK_APP_KEY'),
packageName: 'com.ackbiz.khetihisab',
sender: _Sender(),
tokens: const SecureTokenStore(),
);
final transport = AndroidBillingTransport();
final identity = DeviceIdentity(
read: transport.rawAndroidId,
salt: const String.fromEnvironment('ACK_DEVICE_SALT'),
);
api.deviceId = await identity.get();
await api.registerDevice(AckDeviceInfo(deviceId: await identity.get()));
await transport.connect();
// 3. Is this user Pro? RevenueCat is injected rather than depended on — your
// paywall already has the SDK, and this package should not pin its version.
final entitlements = Entitlements(
revenueCatSaysPro: () async =>
(await Purchases.getCustomerInfo()).entitlements.active.containsKey('pro'),
backendSaysPro: api.isEntitled,
cache: const PreferencesEntitlementCache(),
);
// 4. The router.
final router = BillingRouter(
transport: transport,
alternative: RazorpayAlternativeFlow(
api: api,
askState: showIndianStatePicker, // yours: it decides CGST/SGST vs IGST
),
verifier: ApiPlayVerifier(
api: api,
basePlanFor: (productId) => basePlans[productId],
),
newIdempotencyKey: () => const Uuid().v4(),
);
// 5. The profile screen, when your app has one.
await api.saveProfile(
displayName: name,
email: email, // stored encrypted; never an identity key
phone: phone, // E.164, or the backend refuses it
countryCode: 'IN',
avatar: 'avatar_07', // a preset id from YOUR set, never a URL
clear: emptiedFields, // absent leaves a field; empty erases it
);
Read the cache before the first frame, never a network call. isPro() may
touch RevenueCat, then the backend, then disk — awaiting it before rendering is
a white screen for as long as the slowest link takes. Render from the cached
answer, refresh after the first frame, rebuild if it changed.
Full walkthrough, including what has to be configured in the admin panel and
Play Console before any of this works:
docs/billing/12a-mobile-integration-guide.md.
What this package requires of your app #
Android floors, inherited by anything that adds this package:
| Why | ||
|---|---|---|
minSdk |
23 | flutter_secure_storage 10.x, which needs Keystore AES-GCM |
compileSdk |
36 | Same. A module cannot compile against less than its dependencies |
| Play Billing | 8.x | Declared as a floor. RevenueCat resolves it too, and Gradle takes the highest |
If your app is below either SDK level, raise it in
android/app/build.gradle.kts — this package cannot compile otherwise.
It expects to sit next to RevenueCat. Entitlements takes RevenueCat as an
injected function rather than depending on purchases_flutter, so the version
is yours to choose. That is also why example/ depends on it: the package
compiling alone proves nothing about the configuration every real app is in,
and three defects shipped through exactly that gap.
Configuration: local and live #
Four values, none of them committed. Pass them at build time:
flutter build appbundle --release \
--dart-define=ACK_BASE_URL=https://ackbiz.com \
--dart-define=ACK_APP_KEY=... \
--dart-define=ACK_DEVICE_SALT=... \
--dart-define=RC_PUBLIC_KEY=...
const baseUrl = String.fromEnvironment('ACK_BASE_URL');
const appKey = String.fromEnvironment('ACK_APP_KEY');
const salt = String.fromEnvironment('ACK_DEVICE_SALT');
| Value | From | Local build |
|---|---|---|
ACK_BASE_URL |
https://ackbiz.com |
Your machine's LAN address or a tunnel — not localhost |
ACK_APP_KEY |
Admin panel, at onboarding. Shown once | The local backend issues its own |
ACK_DEVICE_SALT |
The backend's DEVICE_ID_SALT |
Must match the backend you point at |
RC_PUBLIC_KEY |
RevenueCat dashboard | Same key; RevenueCat has no local mode |
ACK_DEVICE_SALT is not a secret in this app's hands — it ships inside every
APK and can be extracted. Its job is to stop a leaked devices table being
reversible to raw Android IDs on its own. Ship it without worrying; just never
change it, for the reason below.
Razorpay keys are not in this list, deliberately. The publishable key
arrives in the /checkout/create response and the secret never leaves the
server, so switching between Razorpay test and live mode is a server-side change
with no app release attached.
What you cannot test locally #
Google Play verifies a purchase token against a backend it can reach, so the Play rail is always an internal-track test against the real server. A debug sideload cannot buy anything, whatever the backend is set to.
The alternative (Razorpay) rail has no such constraint and works against a local backend, which makes it the one to develop against day to day.
Two things that will cost you if you change them #
The salt. DeviceIdentity hashes ANDROID_ID with a build-time salt.
Changing it invalidates every stored device id at once: no seat matches, no
purchase restores, and nothing errors — every user simply appears new. The
backend refuses to boot when its half changes; this side has no such protection.
device_info_plus. Do not reach for androidInfo.id when wiring the
platform read. That is Build.ID, the OS build number, identical on every
device with the same firmware — so every user on a given ROM shares one identity
and therefore everyone else's pro. Use android_id.
Before publishing #
pub publish --dry-run is not sufficient for this package, and has now missed
the same class of bug twice. It validates metadata and archive shape; it does
not check that the archive contains what pubspec.yaml promises.
0.2.0 went out declaring pluginClass: AckBizBillingPlugin with no android/
directory in the tarball, because .pubignore still excluded it from when the
package was pure Dart. Zero warnings. Every consuming app failed its Gradle
sync.
So the release check is a BUILD against the published archive, not a dry run:
# after publishing, in a throwaway app
flutter pub add ackbiz_billing
flutter build apk --debug
Confirming the file list before publishing is the cheap half of it:
flutter pub publish --dry-run 2>&1 | sed -n '/^├── android/,/^├── example/p'
That must show build.gradle, AndroidManifest.xml,
AckBizBillingPlugin.kt and UcbBillingManager.kt, and must NOT show
local.properties -- .pubignore REPLACES .gitignore rather than extending
it, so anything ignored for git has to be repeated there or it ships.
Testing #
flutter test
flutter analyze
The core has no store dependencies, which is deliberate: purchases_flutter,
razorpay_flutter, android_id and flutter_secure_storage all belong to the
platform layer, and keeping them out is what lets this logic be tested without a
device.