DEV Community

Satechs LLC
Satechs LLC

Posted on

Selling a Flutter app on Google Play without a backend: trial, lifetime, subscription and an upgrade

Shiftary is a shift planner for Android, built in Flutter. It works offline with no account, so when it came to charging for it I had one constraint: no server in the purchase path. If my backend is down, nobody should lose access to their rota.

The model ended up as:

  • a 14-day trial of everything, no card
  • Shiftary Lifetime: one-time, $19.99
  • Shiftary Cloud: yearly subscription, $9.99, adds cloud backup and sync
  • Lifetime upgrade: $9.99, only offered to Cloud subscribers, so they never pay full price twice

Here's how each piece works with the in_app_purchase plugin, and the two places where Google Play doesn't give the app what you'd expect.

The trial: earliest date wins

The trial runs on the phone's clock from first launch. Nothing waits for the network, so the calendar opens instantly even in a hospital basement.

The start date is stored in two places: SharedPreferences, and inside the user's own data (the same snapshot that goes into backup files and cloud sync). On every launch the app takes the earliest of the two:

final saved = DateTime.tryParse(prefs.getString(_kTrialStart) ?? '');
final inData = appState.trialStartedAt;
final candidates = [?saved, ?inData];
_trialStart = candidates.isEmpty
    ? _now()
    : candidates.reduce((a, b) => a.isBefore(b) ? a : b);
Enter fullscreen mode Exit fullscreen mode

So restoring a backup or signing in to sync brings the original start date back with your data. A date can only ever move earlier, which can only shorten a trial.

Is it uncrackable? No. Clearing app data and never restoring gets you a new trial. But that's also a person starting from an empty calendar, and I'm fine with that trade.

When the trial ends without a plan, the app doesn't lock you out. It becomes view-only: your shifts are still there, and you can still export a backup. Holding someone's rota hostage is how you earn the 1-star review.

Ownership: ask Play, cache the answer

For "does this person own Lifetime or Cloud?", Play is the source of truth. On Android you can query past purchases directly through the platform addition:

final android =
    _iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final res = await android.queryPastPurchases();
for (final p in res.pastPurchases) {
  if (p.status == PurchaseStatus.purchased ||
      p.status == PurchaseStatus.restored) {
    final w = p.billingClientPurchase;
    // w.orderId, w.purchaseTime, w.isAutoRenewing …
    if (p.pendingCompletePurchase) await _iap.completePurchase(p);
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details worth copying:

  1. Acknowledge anything you find unacknowledged. Play refunds purchases that aren't acknowledged within three days. If the app was killed between the payment and completePurchase, the next launch fixes it.
  2. Cache what you own in SharedPreferences and refresh in the background. Offline, the app trusts the cache; online, Play corrects it.

Play only returns subscriptions that are currently active, so access never depends on a date I worked out myself.

Gotcha 1: Play doesn't give the app the renewal date

I wanted Settings to say "Renews on 12 March 2027", or "Ends on…" if you've cancelled. Without a server calling the Play Developer API, the app gets purchaseTime, isAutoRenewing and orderId. No expiry.

The order ID helps. Renewals of a subscription use the original order ID plus ..0, ..1, and so on. So the number of years paid is the renewal count plus one:

static DateTime cloudPeriodEnd(
    DateTime purchasedAt, String? orderId, DateTime now) {
  final m = RegExp(r'\.\.(\d+)$').firstMatch(orderId ?? '');
  final years = m == null ? 1 : int.parse(m.group(1)!) + 2;
  DateTime plus(int y) => DateTime(purchasedAt.year + y, purchasedAt.month,
      purchasedAt.day, purchasedAt.hour, purchasedAt.minute);
  final end = plus(years);
  // A paid year can't end more than a year from now.
  return end.isAfter(now.add(const Duration(days: 367))) ? plus(1) : end;
}
Enter fullscreen mode Exit fullscreen mode

The final line is a guard: if the arithmetic ever gives a date more than a year away, the app shows a year after purchaseTime instead. It's a label, not access control, so a rare wrong day is acceptable. A wrong access decision wouldn't be, which is why that comes from Play.

Test it with license testers: their yearly subscriptions renew every 30 minutes, so you see ..0, ..1, ..2 within an afternoon.

Gotcha 2: you can't discount a one-time product per user

Cloud subscribers who later decide they want Lifetime shouldn't pay $19.99 on top of what they've paid. Play has no "discount this one-time product for people who own X". So the upgrade is a separate one-time product (shiftary_lifetime_upgrade, $9.99), and the app decides who sees it:

StoreProduct? get lifetimeOffer => hasCloud
    ? _products[kLifetimeUpgradeId] ?? lifetimeProduct
    : lifetimeProduct;
Enter fullscreen mode Exit fullscreen mode

Owning either product counts as Lifetime. Someone could, in theory, find the upgrade product another way. In practice it's only shown to subscribers, and the worst case is a $10 discount. Again, a trade I'm fine with.

And it can be bought straight away. A subscriber who decides on day three that they want Lifetime doesn't have to wait out the year. The plans screen also has a Manage Cloud subscription button that deep-links to the subscription in Play, so cancelling is one tap:

const kManageCloudUrl = 'https://play.google.com/store/account/subscriptions'
    '?sku=shiftary_cloud_yearly&package=app.shiftary';
Enter fullscreen mode Exit fullscreen mode

What I'd do again

  • Keep the server out of the purchase path. Play already knows who paid.
  • Make the safe failure the default. Offline means "trust the cache", trial-over means "view-only", unknown expiry means "a year after purchase".
  • Put the trial date inside the user's data, so it follows them through backups and sync, earliest date wins.

Shiftary is on Google Play, more at shiftary.app. If you've shipped Play Billing in Flutter, I'd love to hear what tripped you up.

Top comments (0)