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.
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.
It has run in a shipped app. Kheti Hisab 2.2.2 carries it on real handsets, and the alternative rail has taken payments through the Razorpay sheet, in Razorpay's test mode, with the grant landing in the backend and the entitlement coming back through this package (September 2026). The app team's review of that build is what produced 0.11.0 through 0.13.0; the integration guide's Part 0 says what each release asks of an app. Live keys, license testers and the Play Console enrolment for User Choice Billing are the backend's launch items, not this package's.
| 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 |
runs in Kheti Hisab on real handsets |
RazorpayAlternativeFlow |
has taken payments, Razorpay test mode; opens with the profile since 0.13.0 |
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 ?? '')
// Reads ask for less; the ceiling holds for everything else.
.timeout(request.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,
);
}
}
// `request.timeout` is set by the API on its reads -- ten seconds, because an
// entitlement check gates a screen and has a week of cache behind it -- and
// left null on anything that creates, which waits the full ceiling. Honour it
// as an upper bound; a sender that ignores it is still correct, only slower
// on a bad connection. `AckHttpClientSender` in `ackbiz_billing/http.dart`
// already does all of this.
// 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'),
// Answers "is this device Pro" AND carries the plan, expiry and grace flag,
// so `resolve()` costs no extra call. `backendSaysPro: api.isEntitled` still
// works and gives you everything except `resolve()`.
backendEntitlement: () => api.entitlement(entitlementId: 'pro'),
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
);
// 6. Confirming that address, if your app asks people to.
final challenge = await api.requestEmailVerification(); // empty body: the
// server mails what
// was last SAVED
if (challenge.status == AckEmailChallengeStatus.sent) {
// challenge.emailMasked is safe to show; challenge.expiresIn is the countdown
final status = await api.confirmEmailVerification(
challengeId: challenge.challengeId!,
code: typed, // trimmed for you
);
// status == AckEmailConfirmStatus.verified -> show the tick
}
Both calls return a value for every outcome, and neither throws for one.
noEmailOnFile sends the customer to the profile form, alreadyVerified skips
the flow, addressChanged means the profile was edited while the mail was in
flight — each needs different words on screen, and an exception would make you
parse a message back into that decision. An unrecognised status arrives as
unknown, which is never read as success.
Save the profile first: there is no email parameter on the request call, and
adding one would make the endpoint a mail relay with our domain on the
envelope.
AckProfile.emailVerified is the durable answer — read it back rather than
remembering your own. It is bool?, and null means the backend did not say,
not "no". The backend drops the flag by itself whenever the address changes, on
this device or any other, so a cached tick outlives the proof that earned it.
Decide which source is allowed to say no. Entitlements asks RevenueCat
first by default and returns true on its yes — so a promotional grant
RevenueCat still holds outranks a plan revoked in /admin, and the backend is
never consulted. If ACKBiz is where your money lives, say so:
Entitlements(
...,
order: AckEntitlementOrder.backendAuthoritative,
);
Then the backend's answer is final including its no, and RevenueCat is only
consulted when the backend cannot be reached. backendFirst is not the same
thing: it reorders the round trips and still returns true if either says yes.
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, and under
backendAuthoritative that round trip is unconditional. lastKnown() is the
answer with nothing asked of anybody:
// Before the first frame. No network, no await on a socket.
final known = await entitlements.lastKnown();
var showPro = known?.isPro ?? false;
runApp(MyApp(showPro: showPro));
// After it, let the live answer correct the guess.
WidgetsBinding.instance.addPostFrameCallback((_) async {
final live = await entitlements.isPro();
if (live != showPro) setState(() => showPro = live);
});
null means no usable answer — nothing resolved yet on this install, or
what was stored is past the offline grace. That is not the same as "not
entitled": a stored false inside the grace comes back as a record whose
isPro is false, so you can draw the paywall for somebody you know is not
entitled and draw nothing yet for somebody you have never asked about.
Getting this wrong has a shape. provider.value ?? false renders not-Pro
while isPro() resolves, which shows a paying customer the upgrade offer for
the length of a round trip — the one mistake in this file that reads to a user
as the app having lost their money.
Restoring covers two rails, and restorePurchases() is one of them.
Purchases.restorePurchases() asks Play. It knows nothing about a Razorpay
purchase, so a Restore button wired to it alone tells alternative-rail
customers "nothing to restore" while the backend is holding their plan. Ask
both, then let the resolver decide:
Future<bool> restore() async {
// Play, via RevenueCat. Failure here is not failure overall.
try {
await Purchases.restorePurchases();
} catch (_) {
// The ACKBiz rail may still have them.
}
// The backend needs no restore call: the device token already identifies
// this device, and `play/verify` has already told it about Play. Asking the
// resolver is the restore.
return entitlements.isPro();
}
Report the result from the resolver, never from restorePurchases() returning
without throwing.
Naming the plan needs resolve(), and on the alternative rail it is the only
way. isPro() answers a bool, so an app wanting the plan's detail asks
somewhere else — in practice RevenueCat, which is exactly the source
backendAuthoritative told it not to trust, and which never hears about a
Razorpay purchase at all. That row said "Active" and nothing else, forever.
final plan = await entitlements.resolve(); // AckEntitlement?, or throws
if (plan == null) {
// Backend says no entitlement.
} else if (plan.inGrace) {
// A renewal is being retried. The honest moment to mention it.
} else if (plan.isLifetime) {
// No renewal date to show.
} else {
show(plan.planCode, renewsOn: plan.expiresAt);
}
resolve() is the backend's record and only the backend's. It does not ask
RevenueCat and does not fall back to the cache, because both of those answer a
bool — and building an entitlement out of a bare yes would mean inventing
isLifetime and expiresAt. Inventing them wrong shows a monthly subscriber a
lifetime plan and never prompts them to renew, which is worse than showing no
detail.
It throws when the backend is unreachable, like the call it wraps. Catch it
and keep the row you were already drawing. Gate access with isPro() or
lastKnown(); use this to draw.
plan.planCode is null for a promotional or support grant, which has no plan
behind it and is still an entitlement.
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.