Skip to main content

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.

Changelog #

0.13.2 #

Two bug fixes. No API change.

The payment sheet opened for orders that could not be paid #

checkout() called /checkout/create and went straight to the Razorpay sheet without reading the status the server had just sent it. Two of the values that field can carry make that wrong, and both are money-shaped.

checkout_unavailable means the provider call never resolved, so there is no order id and no subscription id to pay against. The sheet was opened with a null order_id and failed inside the Razorpay SDK -- an unexplained error on the payment screen, where the server had already said the thing the customer actually needs to hear: "We could not start the payment. You have not been charged."

paid and granted arrive on a REUSED order -- the same idempotency key returning the order it created the first time. The sheet asked a customer to pay for something they had already bought.

creating is the same shape as the first: the provider object does not exist yet, so there is nothing to pay against. Nothing is lost by saying so, because the same idempotency key resumes the same order rather than making a second.

The fix. The sheet opens for exactly one state: status == 'pending' with an id to send. Everything else goes through outcomeForStatus, the same mapping the poll uses, so both paths speak one vocabulary to the customer.

Seven tests, and each one fails against the old code by opening a sheet.

The dead-token retry could not fire on the documented path #

This client advertises that an expired device token is replaced transparently: it re-registers with the device id and replays the call once. The id it re-registers with was recorded by the public deviceId setter and by nothing else -- not by registerDevice, which is handed the id. The example app calls that setter. AckDeviceRegistrar, which is what the integration guide tells you to use, does not.

So for an app built the documented way, _lastDeviceId was null, the retry was skipped, and an expired token surfaced as an AckApiException the app had to handle itself. The two tests that covered the retry set the id by hand, which is why it looked covered.

registerDevice now records the id it is given. The deviceId setter stays for a client rebuilt from a stored token that has not registered in this process.

Kheti Hisab #

Nothing to change for either fix. Bump the dependency: the checkout flow answers the same AckPurchaseResult values it always did, for more of the cases, and the token retry starts working on its own.

0.13.1 #

A bug fix. No API removed; outcomeForStatus gains one optional argument.

The poll could say "Payment received" when nobody had paid #

Reported by the Kheti Hisab team, who found it by reading this package against the backend rather than by hitting it. Nobody had hit it: the alternative rail has not taken a live payment yet.

The problem. AckCheckoutStatus.isPending was !isPaid && !isFailed, and the server has seven statuses rather than two. So granted, refunded and checkout_unavailable all counted as still-moving. The poll only stops on !isPending, so all three ran to the sixty-second deadline and were then reported as "Payment received. Unlocking shortly." — to a refunded customer, to one who had never been charged, and to one who was already granted.

outcomeForStatus had answered two of those three correctly all along, including the sentence "You have not been charged" for exactly the case the poll answered with "Payment received". Those branches were unreachable from this call site.

The half that would have been noticed first. A paid order becomes granted the moment the entitlement exists, which is the normal outcome of a working purchase. So a grant landing before the first poll meant the loop never finished early: every successful alternative-rail purchase spun for the full minute and then said "unlocking shortly" instead of unlocking.

What changed.

  • isPending names the two statuses that are genuinely in flight (creating, pending) instead of deducing them from the absence of two others. Deciding by elimination is what turned a new server status into a silent client bug.
  • refunded has a branch. It had none — the word did not appear anywhere in this package — so it fell to the default and told a refunded customer their payment was still processing.
  • outcomeForStatus takes an optional message, the server's own customer-facing sentence, and prefers it wherever the outcome carries one. AckCheckoutStatus.message documents itself as "show it as written" and the client had its own wording for the same states. A genuine failureReason still wins over both.
  • The deadline no longer claims payment. Only creating and pending keep the loop running, so reaching the deadline means confirmation never came. It now reports the server's last sentence, or says confirmation has not arrived.

isPaid and isFailed are unchanged.

Coverage. A table over every status the server can return: which are in flight, which are terminal, that only granted reports success, that nothing but paid and granted may mention receiving money, and that the server's sentence is preferred. An eighth status added to the backend now fails that table until someone decides what it means.

Still untested, stated rather than implied: the poll loop itself. It is reachable only through the payment sheet, so driving it needs platform-channel mocking that this package does not have. What is tested is the classification the loop turns on, which is where the defect was.

0.13.0 #

Additive. One new optional field on AckCheckout, read by the Razorpay flow.

The sheet opens with the profile #

The problem. A customer typed their email and phone on the profile screen and Razorpay asked for both again on the very next screen, because nothing handed them over.

What changed. POST /api/v1/checkout/create now returns prefill, the email, phone and name the profile already holds, with nulls for what it does not. AckCheckout.prefill carries it and RazorpayAlternativeFlow passes it to the sheet as Razorpay's prefill block, only the fields we hold. A backend older than the field hands over nothing and the sheet asks as it always did.

And the other way round. The backend now keeps the email and phone the customer types into the sheet, when the profile holds neither -- filling the gaps, never overwriting, and never as a verified or identity-bearing address. Nothing to do in the app for that half.

0.12.0 #

Additive. One new optional field, one new optional constructor argument, no default changed for anything that creates.

Reads give up in ten seconds; writes still wait the full ceiling #

The problem. One thirty-second ceiling on the sender served every call, and two of them want opposite numbers. entitlements gates a screen and has a seven-day cache behind it, so on a hung socket it should give up early and let the app show the last answer. createCheckout is a customer being charged, and giving up early on that is worse than making them wait. An app either accepted a thirty-second gate on isPro() or configured one sender per call site, which is the sort of thing that gets configured once and drifts.

What changed.

  • AckRequest.timeout (Duration?): how long this call is worth waiting for. Set by AckBizApi, not by apps, on its two reads: entitlements() (and so isEntitled and entitlement) and checkoutStatus(). Null on everything else.
  • AckBizApi(readTimeout:), default ten seconds. The one knob.
  • AckHttpClientSender honours the field as an upper bound: the shorter of the request's ask and its own ceiling. A request can ask for less patience than the ceiling and never more, because the ceiling is what guarantees a hang resolves at all.

If you wrote your own sender: read request.timeout and treat it the same way; the README snippet shows the one-line form. A sender that ignores it is still correct, only slower on a bad connection.

0.11.0 #

Additive. No default changed, no signature changed.

Entitlements.lastKnown(): the answer before the first frame #

This closes a hole 0.10.0 opened. backendAuthoritative made the backend's answer final, which is right — and it made isPro() an unconditional network call with a 30-second ceiling. Until it resolves an app has nothing to draw, and the obvious reading renders not-Pro:

final showPro = proProvider.value ?? false;   // false while it loads

So a paying customer is shown the upgrade offer for the length of a round trip. That is the one mistake in this package that reads to a user as the app having lost their money. An adopter shipped it and caught it in review, not in testing.

The advice already said to avoid it and the API made it impossible. This package's README and 12a-mobile-integration-guide.md §3.2 ("Never block the UI") both say: read the cache, render, refresh after the first frame. The cache was private and reachable only when BOTH sources had failed, so there was no way to follow it.

// No network. Safe before a frame.
final known = await entitlements.lastKnown();
runApp(MyApp(showPro: known?.isPro ?? false));

null means no usable answer, not "not entitled". Nothing resolved yet on this install, or what was stored is past the offline grace. A stored false inside the grace comes back as a record whose isPro is false — so "we know they are not entitled" and "we have never asked" can draw different pixels, which a bare bool could not express.

The grace rule now has ONE implementation, shared with isPro(). If the two disagreed, an app would render one answer and then correct it to the opposite for no reason a user could see. A test pins them equal across the boundary.

lastKnown(ignoreGrace: true) returns the stale record, for showing when an answer was last confirmed. Not for deciding access.

Entitlements.resolve(): the entitlement, not just a bool #

isPro() answers a bool, so an app wanting the plan's detail asked somewhere else — in practice RevenueCat, which is exactly the source backendAuthoritative had just told it not to trust, and which never hears about a Razorpay purchase at all. An alternative-rail customer could therefore never be shown a renewal date; the row said "Active" and nothing else, permanently.

Entitlements(
  revenueCatSaysPro: ...,
  backendEntitlement: () => api.entitlement(entitlementId: 'pro'),
  cache: ...,
);

final plan = await entitlements.resolve();   // AckEntitlement?

backendEntitlement answers isPro's question as well as carrying the detail, so an app wanting both makes one round trip rather than two. backendSaysPro still works and is unchanged — it simply cannot feed resolve(), which says so with a StateError rather than a null that would read as "not entitled".

resolve() is the backend's record and only the backend's. It does not ask RevenueCat and does not fall back to the cache. That is a decision, not an unfinished edge: both of those answer a bool, and building an AckEntitlement 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 — worse than showing no detail at all. Gate with isPro() or lastKnown(); draw with this.

It throws when the backend is unreachable, like the call it wraps. Unreachable is not "not entitled" anywhere else in this class and it is not here either.

This also makes inGrace reachable for the first time. The integration guide calls it "the honest moment to say a payment needs attention" and no app could say it, because the only path to the flag was a second call that bypassed the resolver.

AckBizApi.entitlement() #

isEntitled reduced the row to a bool and threw the rest away; this is the same lookup keeping it. isEntitled is now written in terms of it, so there is one lookup rather than two that could drift.

AckEntitlement.planCode #

New field, from plan_code on GET /api/v1/entitlements. The only way to name what an alternative-rail customer bought.

Null is a real answer — a promotional or support grant has no plan behind it and is still an entitlement — and it is also what an older backend returns, so the field is absent rather than wrong when talking to one.

A cache read can no longer fail an entitlement check #

_remember had always swallowed a failing write — "a cache write failing must never fail an entitlement check" — and the read had no such guard. So a cache that threw on read propagated out of isPro(), from the one path that exists for when everything else is already unreachable. It now returns null like any other absent answer.

README: restoring covers two rails #

Purchases.restorePurchases() asks Play and 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. An adopter shipped exactly that. There is now a worked example: ask both, report from the resolver, never from restorePurchases() returning without throwing.

0.10.0 #

Additive. No default changed.

AckEntitlementOrder: which source decides #

From a field report — a plan revoked in /admin, and the handset still Pro across three cold launches and two app builds.

The backend was not the problem. GET /api/v1/entitlements excludes a revoked row and now has an end-to-end test saying so, driven through the route with a real device token against that exact state: revoked, seat live, expiry a month out. It returns [].

The resolver never asked it. Entitlements.isPro() asks RevenueCat first and returns true on its yes, so a promotional grant RevenueCat still holds short-circuits the check. The system of record says revoked; the mirror says Pro; the mirror is the one that answers.

Entitlements(
  revenueCatSaysPro: ...,
  backendSaysPro: ...,
  cache: ...,
  order: AckEntitlementOrder.backendAuthoritative,
);

Reordering an OR is not a fix, and the enum says so. revenueCatFirst and backendFirst both return true if either source says yes — they differ only in which round trip happens first. Under both, a revoked plan stays granted while RevenueCat holds the grant. backendAuthoritative is the one that changes the answer: the backend's reply is final including its no, and RevenueCat is consulted only when the backend cannot be reached.

Offline is unaffected. Unreachable is still not "not entitled" — an unreachable backend falls through to RevenueCat and then to the seven-day cache, and there are tests for each.

The cost is honest: a round trip on every check where the RevenueCat SDK would have answered from its own cache. An entitlement check is not on a hot path. The trade worth understanding is the other one — a purchase the backend has not recorded yet reads as no rather than being rescued by RevenueCat. On the Play rail that window is play/verify, which the purchase flow already awaits.

The default stays revenueCatFirst, because changing how every existing adopter resolves access is not a decision a patch release should make quietly. For an app whose money lives in ACKBiz — which is all of ours — the setting that matches the intent is backendAuthoritative.

Not changed: entitlements() already returns rows #

The report also asked for an AckEntitlement with expiresAt, isLifetime and inGrace, and for isEntitled to become a convenience over it. That is already the shape: AckBizApi.entitlements() has returned List<AckEntitlement> since it was written, and isEntitled is implemented on top of it. No change was needed.

0.9.0 #

Additive.

Email verification is callable #

docs/billing/12a-mobile-integration-guide.md §2.7 has specified these two endpoints for a while and the backend has implemented them; the package had no binding for either, so an app could save an address and could be told nothing about whether it was ever checked.

final challenge = await api.requestEmailVerification();

switch (challenge.status) {
  case AckEmailChallengeStatus.sent:
    showCodeScreen(challenge.emailMasked!, challenge.expiresIn!);
  case AckEmailChallengeStatus.noEmailOnFile:
    goToProfileForm();
  case AckEmailChallengeStatus.alreadyVerified:
    showTick();
  case AckEmailChallengeStatus.sendFailed:
    showRetry();
  case AckEmailChallengeStatus.unknown:
    showRetry();
}

final status = await api.confirmEmailVerification(
  challengeId: challenge.challengeId!,
  code: typed,
);

Every outcome is a value; neither call throws for one. Three of the four request outcomes and five of the six confirm outcomes are ordinary things a screen has to draw differently — noEmailOnFile sends the customer to the profile form, alreadyVerified skips the flow, addressChanged means the profile was edited while the mail was in flight. An exception would force every caller to parse a message back into that decision. Transport and auth failures still throw, as everywhere else.

An unrecognised status is unknown, and unknown is never success. A backend newer than the package cannot make this one say verified, and cannot make the request call say sent either — a code-entry screen for a code that was never sent leaves somebody waiting for mail that is not coming. Telling somebody their address is confirmed when the server did not say so is worse than telling them nothing.

code is trimmed here, once. Codes arrive pasted out of a mail app with a trailing space far more often than anyone expects. The server trims too, but a field that looks invalid while somebody is typing is a support ticket, and the fix does not belong in every app.

AckProfile.emailVerified #

bool?, from the profile call. Null means the backend did not say — an older backend omits the field — so an app that draws a cross on null is stating something the server never did. Treat null as unknown.

Requires ACKBiz backend from 2026-09-01, which added email_verified to the POST /api/v1/profile response. Before that change the flag reached the app exactly once, in the reply to /email/confirm, and an app that wanted to draw a tick on the next launch had to keep its own copy — which drifts, because the backend drops the flag on its own whenever the address is edited from any device.

The address is still not echoed by that endpoint, deliberately.

Not added: emailSource on saveProfile() #

Asked for, and refused for a reason worth stating: a device token authenticates a device, not a Google account, so email_source: 'google' would be a claim the server cannot check. Any holder of a device token could post somebody else's address with it and have the backend stamp it verified — which makes email_verified mean "either we mailed a code and they typed it back, or an app said so", and that is not a fact support can rely on.

The shape that works is a Google ID token, verified server-side against Google's keys, with the address read out of the verified token rather than out of the request body. That needs a backend endpoint and a Google client id, and neither exists yet. See the note in the repo issue rather than working around it app-side: an app-side rule the backend never hears about is exactly the drift this was meant to close.

Housekeeping #

registerDevice carries @override. It was the package's only analyzer warning.

0.8.0 #

Additive.

The place of supply is remembered #

The customer was asked for their state on every alternative-rail purchase, and nothing remembered it — so a repeat buyer searched a list of thirty-six entries again to say what they said last time.

RazorpayAlternativeFlow(
  api: api,
  askState: rememberedStateAsker(
    ask: (previous) => StatePicker.show(kAckIndianStates, selected: previous),
  ),
);

It is remembered as a DEFAULT, not as a decision, and that distinction is the whole design. tax_admin_area decides CGST+SGST against IGST and is printed on a tax invoice. A customer who moves from Gujarat to Maharashtra and is silently charged as Gujarat for ever has been invoiced wrongly, by us, repeatedly — and neither side would notice. So the picker still opens; it opens with their state already selected. A search becomes a tap, and it stays correct when they move.

A cancelled picker does not overwrite a stored answer. Storing the null would make the next purchase ask from scratch, which is the behaviour this replaces.

An app that genuinely wants no second question can return previous from ask without showing anything — but that is a decision to take explicitly, not one to inherit from a default.

0.7.0 #

Additive. Three gaps reported from device testing, all real.

The seat chooser can be answered now #

The receiving half existed -- AckSeat, seatChoiceRequired -- and there was no way to send the answer back. The chooser was reachable and unusable.

  • AckBizApi.verifyPlayPurchase(releaseSeatId:), and the same parameter on PlayVerifier.verify and BillingRouter.purchase. There is no separate endpoint: the app repeats the purchase with the customer's choice attached.
  • invalid_seat_selection is now a seatChoiceRequired, not an error. It means the chooser was stale -- the seat is already gone -- and the response carries a FRESH list the app re-renders from. Reporting it as a failure left a paying customer stuck at a screen they could not get past.
  • cooldown_blocked carries retryAfter, parsed from the response and put in the message. A limit with no date is indistinguishable from a fault, and the customer contacts support about a rule working as intended.

releaseSeatId with trigger: 'launch_sync' is a 400 from the backend, by design: a background call that can evict is the loop that locked a real household out of v3.

X-ACK-App-Version and X-ACK-Device-Model #

AckBizApi(appVersion:, deviceModel:) sends both on every authenticated call. Every adopter was wrapping the sender to add them, which is the shape that says they belong here.

device/register runs once per install, so without these the admin panel shows the version a customer INSTALLED rather than the one they are running -- misleading in exactly the situation support uses it. Optional, and the backend COALESCEs, so an app that sends neither cannot erase what an earlier call recorded.

AckBillingConfig.maybeFromEnvironment() #

fromEnvironment threw unconditionally, and flutter run plus the whole test suite legitimately have no defines -- so billing could not be wired into an app that must also run without it.

maybeFromEnvironment returns null instead. It still throws in a release build, and that is the design: absent defines in debug are a developer running locally; absent defines in a release build are a build reaching the store unable to register a device or take a payment.

0.6.0 #

Breaking, one signature. Reported from the first adopter's device testing.

isEntitled requires the entitlement id #

isEntitled({String entitlementId = 'pro'}) had a default, and the default was wrong for the app that reported it — Kheti Hisab uses Kheti Hisab Pro.

It is a string, not a convention. pro is the column default in the backend's schema, which is not the same as being the value.

A default was worse than none here because the failure is unfalsifiable from the app side. Entitlements asks RevenueCat first and the backend second, so with the app and RevenueCat agreeing and only this arm wrong, Pro keeps working through RevenueCat while the backend arm quietly returns false. Nothing looks broken on any screen. You find out when a customer goes offline long enough to fall through — or you never do, until they complain.

Pass one constant to both arms. Two literals is how they drift, and the drift is silent.

AckPlan.unmatched — which store products have no plan row #

A DIAGNOSTIC, not a fallback.

forStoreIdentifier refuses to guess, which is right — and it leaves a paywall silently one row short with nothing saying why. The first adopter hit exactly this: a Test Store lifetime product identified lifetime, a catalogue row for kheti_hisab_pro_lifetime, no match, and the row simply disappeared.

final missing = AckPlan.unmatched(
  offerings.map((o) => o.storeProduct.identifier),
  BillingPlans.catalogue,
);
if (missing.isNotEmpty) log('no plan row for: $missing');

The fix when it returns anything is a catalogue row, never a looser lookup. Matching by suffix would be a guess about a price.

0.5.0 #

Additive. Nothing from 0.4.0 changes.

The second device can attach now #

queryPurchases did not exist, and its absence was a capability gap rather than a convenience one. The purchase event only fires for a purchase made on THIS device in THIS session, so a customer who bought on one phone and installed on a second had nothing to send the backend — the second phone looked like it had never paid, and no amount of app code could fix it.

  • AndroidBillingTransport.queryPurchases() → AckOwnedPurchases. Queries BOTH product catalogues: subscriptions and one-time products live in separate Play catalogues, and asking for one silently returns nothing for the other — which reads as "no purchases" for exactly the customers who bought the other kind. Returns a PARTIAL answer with an error rather than failing outright, because one catalogue answering restores whoever bought from it. PURCHASED only: a PENDING purchase is a payment method that settles later, and sending one as a restore asks the backend to grant something nobody has paid for yet.
  • AckPurchaseSync.syncAll(owned, trigger:) verifies each against the backend and reports the strongest outcome. An account can hold a live lifetime purchase and a lapsed subscription; reporting the last one processed would make the answer depend on Play's ordering. trigger defaults to 'launch_sync', which the backend refuses to move a seat on — a background call that can evict is the loop that locked a real household out of v3. Pass 'restore' when a person tapped it. Nothing owned is an ANSWER, not an error. A failed QUERY is an error, because only that one is worth retrying.

acknowledgePurchase is deliberately NOT added #

The spec asked for it alongside queryPurchases. It should not exist here.

Acknowledgement is the backend's, after it has verified with Google — UcbBillingManager.handlePurchase has said so since it was written. Acknowledging on the device tells Google we delivered something we have not confirmed, and the pressure to add it is real: Google auto-refunds anything unacknowledged after three days, so "just acknowledge it here" looks like insurance. It is the opposite. The backend queues google_ack once it has checked the purchase is genuine, and a client that acknowledged first would have thrown away the only window in which a forged token can still be refused.

0.4.0 #

Breaking. The migration is mechanical and the compiler finds every site.

The plan is a value now, and it fixes a charging bug #

AndroidBillingTransport.launchProduct always took a base plan and a subscription flag. BillingTransport -- the interface the router calls -- carried only a product id, so the capability existed and the seam refused to pass it. Every adopter wrote the same two adapter classes to smuggle the plan across, through mutable state on the enclosing flow.

That was not merely ugly. Launching a subscription without naming its base plan takes whichever one Play lists first, which is how a customer is charged for an introductory offer nobody configured. The adapters existed to prevent that, so an adopter who skipped them -- or wrote them slightly wrong -- had a billing bug that only appears on a subscription with more than one base plan.

  • AckPlan carries planCode, productId, basePlanId and isSubscription. AckPlan.forStoreIdentifier does the colon split, because Play names a subscription productId:basePlanId and every adopter otherwise has to discover that from a lookup that silently returns null. An AMBIGUOUS identifier returns null rather than the first match: a bare product id sold under two base plans is the same silent guess, and a caller can refuse a null but cannot notice a plausible wrong price.
  • BillingTransport.launchBillingFlow({plan, useUcb})
  • PlayVerifier.verify({plan, purchaseToken, idempotencyKey}) -- the plan arrives per CALL. It used to be configured at construction, which cannot work: which plan was bought is not known until the customer taps one.
  • BillingRouter.purchase({plan, useUcb})
  • AndroidBillingTransport implements the interface directly. launchProduct stays for a caller holding the four values loose.
  • ApiPlayVerifier({api}) -- basePlanFor and isSubscription are gone, and one instance now serves every plan the app sells.
  • BillingRouter.onPlayPurchase({purchaseToken}) no longer takes a product id. It was a second source for a fact the plan already carries, and the BACKEND is the authority on whether the plan matches the token -- it asks Google and answers plan_mismatch. Checking it in the router would be worse than not: a silent return leaves the paywall spinning until the fifteen-minute timeout, and an error would let a duplicate callback kill the current attempt.

Migrating: build one AckPlan per row in your plan list, pass it to purchase, and delete the two adapter classes and the _inFlight field.

Register once, as a policy the package owns #

device/register is the only endpoint that can touch a seat. An app that calls it on every launch spends the customer's seat-change allowance on nothing, and COOLDOWN_BLOCKED then refuses the change they actually wanted -- moving their subscription to a new phone. The code that causes that looks entirely reasonable: await api.registerDevice(...) in an initialiser.

That is a rule of the rail, not of any app, so it lives here now.

  • AckDeviceRegistrar.ensureRegistered(() => deviceInfo) 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 -- the model, the version, a hashed ANDROID_ID -- and a relaunch should not pay for any of them.
  • AckRegistration is three states, not a bool: alreadyRegistered, registered, failed. "Did this device ever register?" was unanswerable from a tester's phone until the first adopter added that distinction to its own logs, and it is the first question when a seat goes missing.
  • A failure is REPORTED, not thrown. Registration runs at startup, and throwing there takes down app launch for a billing feature the customer may not be using. The next call that needs a token registers through the API client's own 401 path.

The rest of what every adopter was rewriting #

  • package:ackbiz_billing/http.dart -- a ready-made AckHttpSender over package:http, behind a SEPARATE entry point so an app implementing the seam itself imports none of it. It exists because the seam carries a rule that is expensive to get wrong: return normally for every status code, 500 included; throw only when no response arrived. A hand-written sender reaches for if (status >= 400) throw because it reads like error handling, and that collapses "the server refused" into "we do not know" -- opposite instructions for a call that creates an order, and how one customer gets charged twice. Now pinned by tests rather than a doc comment. The sender also LOWER-CASES response headers. AckResponse documented that and nothing enforced it; a capitalised key would make the renewed-token lookup miss silently, and the device would then re-register, which is the one call that can touch a seat.
  • kAckIndianStates and ackStateForCode -- tax data of the rail, not of any app. /checkout/create refuses a tax_admin_area it does not recognise, so every Indian app carrying its own copy is a chance to drift from the list the server validates against. Generated from the backend's own table. The gaps at 25 and 28 are real: Daman and Diu merged in 2020 and Andhra Pradesh renumbered to 37. There is a test, because filling them in is a reasonable-looking thing to do to a list of codes.
  • DeviceIdentity.platform(salt:) -- the raw id over the package's own channel, so an app no longer constructs a billing transport to get one string. The channel name now has ONE definition, imported by the transport. salt stays required: a default would mean every app computing the same device ids.
  • AckBillingConfig.fromEnvironment() -- the same three defines, read the same way, and the rule about a missing one. It THROWS rather than asserting, because asserts are stripped from release builds -- which is exactly the build that reaches a customer with an empty app key. An empty salt is refused for a worse reason: every device id is hashed with it, so shipping without one makes every customer look new, silently, with nothing erroring.

AckDeviceInfo: platform in, install_id out #

  • platform ('android' / 'ios') is now sent. The admin panel shows it per device; a null there means "an old build", not "an unknown device".
  • installId is gone. The backend accepted it and threw it away -- no column, no reader -- and removed it outright. Sending it posted a value into nothing. Do not reintroduce it to tell a reinstall from a fresh install: device_id is built to SURVIVE a reinstall, and that is the feature.

The package ships its own ProGuard rules #

This was a release-only payment crash, and every adopter hit it.

Razorpay delivers the payment result reflectively -- it invokes onPaymentSuccess / onPaymentError by name. R8 full mode, the default since AGP 8, cannot see a reflective call and strips exactly those methods: the sheet takes the money and the result never arrives. A debug build proves nothing, because R8 does not run, so it is discovered in a release build on a real payment.

android/consumer-rules.pro now ships with the package and consumerProguardFiles merges it into every consuming app. Apps carrying these rules in their own proguard-rules.pro can delete them.

Verifying it: a release build with isMinifyEnabled true, a real purchase on the alternative rail, and the result arriving.

0.3.0 #

  • AckBizApi.saveProfile() — the profile a user fills in inside the app. Creates the billing customer if the device has none, so a person exists in the system from the moment they fill it in rather than from the moment they pay.
  • AckProfile, the saved profile as the backend stored it. The email is deliberately not echoed back: the app already holds what the user typed, and the response travels further than the form did.
  • Absent means "leave it", empty means "clear it". Dart cannot tell null meaning "do not touch" from null meaning "erase", so clear names the fields to erase and everything else untouched is omitted from the request. A profile screen that sends its whole form each time passes every field it shows and puts the emptied ones in clear.
  • avatar is a preset id from the app's own set -- avatar_07, never a URL and never an upload. The backend refuses a URL by shape.

0.2.1 #

0.2.0 cannot be used. Use this instead.

Its archive shipped with no android/ directory while pubspec.yaml declared pluginClass: AckBizBillingPlugin, so every consuming app failed its Gradle sync with "doesn't have a main class defined". The package was correct in git; only the published tarball was wrong.

.pubignore still carried android/ from when the package was pure Dart and the native side was genuinely unreferenced. 0.2.0 declared the plugin and that line was never removed, so the release named an entry point it did not contain.

pub publish --dry-run passed with zero warnings, which is the point worth keeping: it validates metadata and archive shape, not whether a plugin builds. The only check that catches this is resolving the published archive in a real app, and README.md now says so under "Before publishing".

Nothing else changed. The code is identical to 0.2.0.

0.2.0 #

The store integrations. 0.1.0 was pure Dart and said so -- "not an Android plugin yet" -- and this is the release where it becomes one: the native module, the transport, both payment rails, and the offline entitlement cache.

It raises the floor for consuming apps: minSdk 23 and compileSdk 36. Both come from flutter_secure_storage 10.x. See "What this package requires of your app" in the README.

Still never used in a real purchase. Everything here compiles, is tested against fakes, and links correctly beside RevenueCat. None of it has taken money on a handset. example/ is the harness for doing that, and until it has been run this is a 0.x package in the sense that matters.

A device test harness, and the defect finding it turned up #

  • BillingRouter.onFailed -- a purchase that Play REFUSED had no way to resolve. The transport built AckBillingFailed from the native error event and nothing consumed it, so a refusal left the attempt pending until the fifteen-minute safety timeout: a paywall spinner that never stops. ITEM_ALREADY_OWNED reaches this on the most ordinary path in repeat testing -- buy, test, buy again -- so it would have shown up within minutes of the first device run, looking like a hang rather than a refusal.
  • example/ is now that device harness rather than a link test. It shows every intermediate value: device id, what Play returned, what the backend says, what RevenueCat says. The last two are shown SEPARATELY rather than through Entitlements.isPro() -- a backend true with a RevenueCat false is the rc_grant job failing, and one boolean cannot say that.
  • It configures RevenueCat with the DEVICE ID as the app user id, because that is what the backend grants against. Anything else and the grant lands on a customer the app never asks about.
  • INTERNET moves into the example's main manifest. Flutter's template puts it in src/debug only, which is a trap here: Play cannot sell to a debug sideload, so every real test is a release build on an internal track -- one that would have shipped with no network permission and failed every backend call in a way that reads as a server fault.

Three fixes for the configuration every consuming app is actually in: alongside RevenueCat. None of them reproduced with the package on its own, which is why example/ now carries purchases_flutter and package_info_plus — the build here fails now instead of eight apps failing one at a time.

  • com.android.billingclient:billing replaces billing-ktx, and the Kotlin moves to the Billing 8 queryProductDetailsAsync shape. RevenueCat pulls billing to 8.x; Gradle keeps one version per artifact and nothing else in the graph mentioned billing-ktx, so it stayed on 7.1.1 — 8.x core classes and 7.x ktx classes linking against each other on one classpath. Nothing here ever used the ktx coroutine wrappers, so depending on the artifact RevenueCat depends on removes the failure mode rather than patching this instance of it.
  • flutter_secure_storage moves to 10.x. On 9.x it reaches flutter_secure_storage_windows 3.x and therefore win32 ^5, which cannot co-exist with the win32 ^6 that package_info_plus requires. Windows is a platform no ACKBiz app ships and flutter pub get fails anyway — pub solves every platform whether or not you build for it.
  • AndroidOptions(encryptedSharedPreferences: true) is gone rather than replaced. Google deprecated the Jetpack library behind that flag; 10.x defaults to AES-GCM with RSA-OAEP key wrapping, which is stronger than what the flag used to select, and migrates existing values on first read.
  • compileSdk 35 to 36 and minSdk 21 to 23, both required by flutter_secure_storage 10.x. This raises the floor for consuming apps.

The alternative rail and the entitlement cache #

  • RazorpayAlternativeFlow — create the order, open the sheet, poll for the truth. The sheet closing is NOT the outcome: Razorpay's callback says the customer completed payment on their device, not that money settled or access was granted, so it starts a poll rather than ending the purchase.
  • A cancel and a failure arrive through the same Razorpay callback, told apart only by a code. Showing "payment failed" to somebody who pressed back is how a customer who changed their mind is told something went wrong.
  • A timeout on create returns pendingGrant, never "you have not been charged". The order may exist; the next attempt reuses the same idempotency key.
  • ApiPlayVerifier — and an unreachable backend after a Play purchase is pendingGrant, because the purchase is real and launch sync will retry it.
  • PreferencesEntitlementCache — plain preferences on purpose, negative answers cached too, and the timestamp stored rather than a countdown.
  • NOT purchases_flutter. Entitlements already injects RevenueCat as a function, so wiring it is three lines in an app that has the SDK anyway.

The Android platform layer #

The package is a real plugin now: the module, the bridge, and the Dart side that talks to it.

  • android/build.gradle, a manifest, and AckBizBillingPlugin behind UcbBillingManager, compiled against Play Billing 8.
  • AndroidBillingTransport — the first real BillingTransport. Two channels, because a purchase is not the answer to a question: opening the sheet returns as soon as it is up, and the outcome arrives later, out of band, sometimes more than once.
  • SecureTokenStore — the device token in the Keystore via flutter_secure_storage. The cached entitlement answer stays in plain preferences: it is a decision, not a credential.
  • ANDROID_ID is read over the plugin's own channel rather than adding android_id for one string. Hashing stays in Dart, where the salt and separator already match the backend exactly.
  • queryProduct on the manager, and an offer token matched by base plan id — launching without one takes whichever offer Play lists first, which is how a customer is charged for an introductory offer nobody configured.
  • Built-in Kotlin rather than our own KGP, so consuming apps do not inherit a deprecation warning that becomes a build failure.
  • example/ exists to compile the module. flutter analyze never touches Kotlin and pub publish validates metadata, so without it nothing proves the plugin links.

AckBizApi — the transport #

All seven endpoints of the billing API, so the package can speak to the backend rather than describing how it would.

  • registerDevice, entitlements, createCheckout, checkoutStatus, verifyPlayPurchase, deeplink, and isEntitled over the top of them.
  • AckHttpSender — a nine-line seam instead of a dependency on package:http, so the transport is testable without a network and no app is forced onto a second HTTP client to make one billing call.
  • AckApiException and AckUnreachable, kept apart. A refusal may not be retried; an unreachable call MAY have succeeded, so the answer there is to reconcile rather than send it again with a new idempotency key.
  • Both auth headers on every call. requireDevice authenticates the app key and then the token, so sending only the token answers INVALID_APP_KEY — which reads like a bad key and is really a missing header.
  • The renewed device token is stored whenever it appears, on any response, rather than waiting for a 401: re-registration is the only path that can touch a seat.
  • A dead token is retried exactly once, and only with a device id to hand. A loop is how a rate limit turns into a device that can never sign in again.

0.1.0 #

First release. The core, with no store SDKs in it yet.

  • BillingRouter.purchase() — one call covering Google Play, User Choice Billing and the ACKBiz alternative rail. The app never branches on which rail handled it.
  • AckPurchaseResult — five outcomes, including the two that usually get forgotten: seatChoiceRequired when the customer is at their seat limit, and pendingGrant when the payment succeeded and delivery has not caught up.
  • Entitlements.isPro() — RevenueCat first, the backend second, and a cached answer inside a grace window third, so a paying customer offline keeps what they paid for.
  • DeviceIdentity — a stable hashed device ID, computed once per install.
  • One idempotency key per purchase attempt, reused by every retry inside it, so a retried network call cannot become a second charge.

Store integrations (purchases_flutter, razorpay_flutter, android_id, flutter_secure_storage) belong to the platform layer and are added as the phases that need them land. Keeping the core free of them is what lets its logic be tested without a device.

Not an Android plugin yet. The core is pure Dart and imports no Flutter platform code. The plugin declaration and the native module arrive together in a later version; declaring one before the other exists would break the Gradle build of every app that added this package.

0
likes
150
points
256
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