The Garage SDKs, in the open

garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+499
View File
@@ -0,0 +1,499 @@
import "dart:async";
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:garage_auth/garage_auth.dart";
import "package:http/http.dart" as http;
import "package:url_launcher/url_launcher.dart";
import "jwks_verify.dart";
import "licence_cache.dart";
import "models.dart";
import "sheet/sheet.dart";
// In-app purchasing for one app. Takes a signed-in GarageAuth and the store API
// base url, lists products, runs both purchase flows, and keeps an offline
// licence. It never touches sign-in — that's garage_auth's job; we just borrow
// its authed client so the bearer + refresh are handled for us.
//
// final iap = GarageIap(
// auth: auth,
// appSlug: "my-app",
// apiBaseUrl: "https://store.imbenji.net/api",
// );
// final products = await iap.products();
// await iap.purchase(products.first, mode: PurchaseMode.sheet);
// final pro = iap.has("pro");
//
// it's a ChangeNotifier so UIs can rebuild when entitlements move.
/// Where the payment portal lives. Overridable per call, but this is the one
/// buyers actually get sent to.
const String kGaragePortalBaseUrl = String.fromEnvironment(
"GARAGE_PORTAL_BASE_URL",
defaultValue: "https://pay.imbenji.net",
);
class GarageIap extends ChangeNotifier {
GarageIap({
required this.auth,
required this.appSlug,
required this.apiBaseUrl,
this.merchantName = "Garage",
LicenceCache? licenceCache,
Duration pollTimeout = const Duration(minutes: 3),
}) : _cache = licenceCache ?? SecureLicenceCache(),
_pollTimeout = pollTimeout;
final GarageAuth auth;
final String appSlug;
final String apiBaseUrl;
final String merchantName;
final LicenceCache _cache;
final Duration _pollTimeout;
// last known entitlements from an online fetch. has() reads this first.
List<GarageEntitlement> _entitlements = [];
List<GarageEntitlement> get cachedEntitlements =>
List.unmodifiable(_entitlements);
// product id -> sku, learned from products(). entitlements are keyed by id, so
// we need this to answer has(sku) off an online entitlement.
final Map<String, String> _skuById = {};
// the last verified offline licence, if we've loaded one this session.
GarageLicence? _licence;
Uri _u(String path, [Map<String, String>? q]) {
final base = apiBaseUrl.endsWith("/")
? apiBaseUrl.substring(0, apiBaseUrl.length - 1)
: apiBaseUrl;
return Uri.parse("$base$path").replace(queryParameters: q);
}
// -------------------------------------------------------------------------
// catalogue
// -------------------------------------------------------------------------
// this app's buyable products (paywall + IAP items). active only for buyers.
Future<List<GarageProduct>> products() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/apps/$appSlug/products"));
if (resp.statusCode != 200) {
throw IapError("Could not load products (${resp.statusCode}).",
code: "products_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final list = (body["products"] as List? ?? []);
final out = list
.whereType<Map>()
.map((m) => GarageProduct.fromJson(Map<String, dynamic>.from(m)))
.toList();
for (final p in out) {
_skuById[p.id] = p.sku;
}
return out;
}
// -------------------------------------------------------------------------
// entitlements
// -------------------------------------------------------------------------
// the caller's grants across every app. caches the result so has() can answer
// synchronously afterwards. throws on network failure (offline -> use the
// licence path instead).
Future<List<GarageEntitlement>> entitlements() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/entitlements"));
if (resp.statusCode != 200) {
throw IapError("Could not load entitlements (${resp.statusCode}).",
code: "entitlements_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final list = (body["entitlements"] as List? ?? []);
_entitlements = list
.whereType<Map>()
.map((m) => GarageEntitlement.fromJson(Map<String, dynamic>.from(m)))
.toList();
notifyListeners();
return cachedEntitlements;
}
// quick own/not-own for this app's paywall product. lighter than entitlements().
Future<bool> owned() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/apps/$appSlug/entitlement"));
if (resp.statusCode != 200) {
throw IapError("Could not check entitlement (${resp.statusCode}).",
code: "entitlement_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return body["entitled"] == true;
}
// -------------------------------------------------------------------------
// purchase
// -------------------------------------------------------------------------
// buy [product]. sheet tries the embedded flutter_stripe sheet and falls back
// to the browser handoff (subscriptions, or platforms with no native sdk).
// handoff always uses the browser. either way we poll entitlements after the
// user-facing step — the webhook is the real source of truth, the redirect /
// sheet-close is just a nudge.
// -------------------------------------------------------------------------
// the payment portal
// -------------------------------------------------------------------------
/// Send the buyer to the Garage Payment Portal for [productId].
///
/// The portal is a different origin, so it cant see this app's token. We mint
/// a one-shot handoff code off the signed-in session and hang it on the url —
/// the portal trades it for the session and the buyer never sees a login.
///
/// [returnUrl] has to be registered on the project or checkout refuses it.
Future<Uri> portalUrl(
String productId, {
String? returnUrl,
String? portalBaseUrl,
}) async {
_requireSignedIn();
final base = portalBaseUrl ?? kGaragePortalBaseUrl;
final trimmed =
base.endsWith("/") ? base.substring(0, base.length - 1) : base;
final query = <String, String>{};
if (returnUrl != null && returnUrl.isNotEmpty) {
query["return_url"] = returnUrl;
}
final code = await _mintHandoffCode();
if (code != null) query["handoff"] = code;
return Uri.parse(
"$trimmed/pay/$productId",
).replace(queryParameters: query.isEmpty ? null : query);
}
/// One-shot code from auth, or null when we couldnt get one — the portal
/// still works then, the buyer just has to sign in when they land.
Future<String?> _mintHandoffCode() async {
try {
final resp = await http.post(
Uri.parse("${auth.issuer}/auth/handoff/issue"),
headers: {"Authorization": "Bearer ${auth.accessToken}"},
);
if (resp.statusCode != 200) {
print(
"[garage_iap] handoff issue refused (${resp.statusCode}): ${resp.body}");
return null;
}
final json = jsonDecode(resp.body) as Map<String, dynamic>;
return json["handoff_code"] as String?;
} catch (error, stack) {
print("[garage_iap] couldnt mint a handoff code: $error");
print(stack);
return null;
}
}
Future<PurchaseOutcome> purchase(
GarageProduct product, {
PurchaseMode mode = PurchaseMode.sheet,
String? returnUrl,
}) async {
_requireSignedIn();
if (mode == PurchaseMode.handoff) {
return _handoff(product, returnUrl);
}
return _sheet(product, returnUrl);
}
// browser handoff: POST /checkout, open the hosted url, poll until granted.
Future<PurchaseOutcome> _handoff(
GarageProduct product, String? returnUrl) async {
final resp = await auth.client.post(
_u("/v1/checkout"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"product_id": product.id,
if (returnUrl != null) "return_url": returnUrl,
}),
);
if (resp.statusCode != 200) {
throw _checkoutError(resp);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final url = body["checkout_url"] as String?;
if (url == null || url.isEmpty) {
throw IapError("Checkout returned no url.", code: "no_checkout_url");
}
final opened = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!opened) {
throw IapError("Could not open the checkout page.",
code: "launch_failed");
}
return _pollForGrant(product);
}
// embedded sheet: stripe-config -> payment-intent -> present. on a subscription
// (or no native sheet) the server / platform tells us to hand off instead.
Future<PurchaseOutcome> _sheet(
GarageProduct product, String? returnUrl) async {
// grab the publishable key up front (also confirms config is reachable).
final cfg = await auth.client.get(_u("/v1/stripe-config"));
if (cfg.statusCode != 200) {
throw IapError("Could not load stripe config (${cfg.statusCode}).",
code: "stripe_config_failed");
}
final pubKey = (jsonDecode(cfg.body)
as Map<String, dynamic>)["publishable_key"] as String?;
final resp = await auth.client.post(
_u("/v1/payment-intent"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"product_id": product.id,
if (returnUrl != null) "return_url": returnUrl,
}),
);
if (resp.statusCode != 200) {
throw _checkoutError(resp);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
// subscription -> server handed back a hosted url. open it like a handoff.
if (body["fallback"] == "handoff") {
final url = body["checkout_url"] as String?;
if (url == null || url.isEmpty) {
throw IapError("Handoff fallback with no url.",
code: "no_checkout_url");
}
final opened =
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
if (!opened) {
throw IapError("Could not open the checkout page.",
code: "launch_failed");
}
return _pollForGrant(product);
}
final clientSecret = body["client_secret"] as String?;
final publishable = (body["publishable_key"] as String?) ?? pubKey;
if (clientSecret == null || publishable == null) {
throw IapError("Payment intent missing client_secret.",
code: "no_client_secret");
}
final result = await presentSheet(SheetParams(
clientSecret: clientSecret,
publishableKey: publishable,
// third-party PI lives on the connected account.
stripeAccount: body["stripe_account"] as String?,
customer: body["customer"] as String?,
ephemeralKey: body["ephemeral_key"] as String?,
merchantName: merchantName,
));
switch (result) {
case SheetResult.unsupported:
// no native sheet here -> degrade to the browser handoff.
return _handoff(product, returnUrl);
case SheetResult.canceled:
return PurchaseOutcome.canceled;
case SheetResult.completed:
return _pollForGrant(product);
}
}
// poll /entitlements until the product shows up active, with a backoff. the
// webhook can lag the redirect by a few seconds, hence the wait. returns
// pending (not an error) if the grant doesnt land before the timeout — it may
// still arrive, the caller can re-check later.
Future<PurchaseOutcome> _pollForGrant(GarageProduct product) async {
final deadline = DateTime.now().add(_pollTimeout);
var wait = const Duration(seconds: 2);
while (DateTime.now().isBefore(deadline)) {
try {
final ents = await entitlements();
final granted = ents.any((e) => e.productId == product.id && e.active);
if (granted) return PurchaseOutcome.granted;
} catch (e) {
// a transient error mid-poll shouldnt kill the whole wait.
print("garage_iap poll error: $e");
}
await Future.delayed(wait);
// gentle backoff, capped so we still check reasonably often.
final next = wait.inMilliseconds * 2;
wait = Duration(milliseconds: next > 8000 ? 8000 : next);
}
return PurchaseOutcome.pending;
}
// -------------------------------------------------------------------------
// offline licence
// -------------------------------------------------------------------------
// Returns a verified licence for this app. Online: fetches the licence JWT AND
// the JWKS in the same trip, caches both, verifies, returns it. Offline (the
// fetch throws): falls back to the cached licence and verifies it locally.
//
// No cached licence + offline => null (treated as not entitled until the first
// online fetch). Cached but past exp while offline => throws expired, also not
// entitled until we can re-fetch.
Future<GarageLicence?> licence({bool forceRefresh = false}) async {
_requireSignedIn();
if (!forceRefresh) {
// try a fresh online fetch first; fall through to cache on any failure.
try {
return await _fetchAndCacheLicence();
} catch (e) {
print("garage_iap licence online fetch failed, trying cache: $e");
}
} else {
return _fetchAndCacheLicence();
}
return _loadCachedLicence();
}
// the cached, offline-verified licence — no network at all. handy for a fast
// boot-time gate before you've been back online.
Future<GarageLicence?> cachedLicence() => _loadCachedLicence();
Future<GarageLicence> _fetchAndCacheLicence() async {
// licence + jwks in the same online trip, so offline always has the key.
final licResp = await auth.client.get(_u("/v1/licence", {"app": appSlug}));
if (licResp.statusCode != 200) {
throw IapError("Licence fetch failed (${licResp.statusCode}).",
code: "licence_failed");
}
final licBody = jsonDecode(licResp.body) as Map<String, dynamic>;
final token = licBody["licence"] as String?;
if (token == null || token.isEmpty) {
throw IapError("No licence in response.", code: "no_licence");
}
// jwks is public, no auth needed — but the authed client works fine for it.
final jwksResp = await auth.client.get(_u("/v1/licence/jwks.json"));
if (jwksResp.statusCode != 200) {
throw IapError("JWKS fetch failed (${jwksResp.statusCode}).",
code: "jwks_failed");
}
final jwks = jsonDecode(jwksResp.body) as Map<String, dynamic>;
final sub = await _subject();
final verified =
verifyLicence(token, jwks, expectedApp: appSlug, expectedSub: sub);
// only cache once it verifies — never persist a bad licence.
await _cache.write(appSlug, CachedLicence(token: token, jwks: jwks));
_licence = verified;
notifyListeners();
return verified;
}
Future<GarageLicence?> _loadCachedLicence() async {
final cached = await _cache.read(appSlug);
if (cached == null) return null;
final sub = await _subject();
// throws on expiry / bad sig — let it propagate so the caller can tell
// "no licence" (null) from "expired offline" (throw).
final verified = verifyLicence(cached.token, cached.jwks,
expectedApp: appSlug, expectedSub: sub);
_licence = verified;
return verified;
}
// resolve the user id we expect in the licence. profile() round-trips userinfo
// — fine, it's cached behind the authed client and only needed at fetch time.
String? _cachedSub;
Future<String> _subject() async {
if (_cachedSub != null) return _cachedSub!;
final me = await auth.profile();
final sub = me?["sub"] as String?;
if (sub == null || sub.isEmpty) {
throw IapError("No subject in profile — cant match the licence.",
code: "no_subject");
}
_cachedSub = sub;
return sub;
}
// -------------------------------------------------------------------------
// has() — the one-liner the apps actually call
// -------------------------------------------------------------------------
// true if [sku] is owned. checks the last online entitlements first, then the
// in-memory verified licence. it's synchronous on purpose — call entitlements()
// or licence() to refresh, then has() to read. for a cold offline start, call
// cachedLicence() once to populate, then has().
bool has(String sku) {
// online: an active entitlement whose product resolves to this sku. needs
// products() to have run so we know the id->sku map.
for (final e in _entitlements) {
if (!e.active) continue;
if (_skuById[e.productId] == sku) return true;
}
// offline: the verified, unexpired licence vouches for the sku directly.
final lic = _licence;
if (lic != null && !lic.isExpired && lic.grants(sku)) {
return true;
}
return false;
}
// -------------------------------------------------------------------------
Future<void> clearLicence() => _cache.clear(appSlug);
void _requireSignedIn() {
if (!auth.isSignedIn) {
throw IapError("Not signed in — call auth.signIn() first.",
code: "not_signed_in");
}
}
IapError _checkoutError(http.Response resp) {
try {
final b = jsonDecode(resp.body) as Map<String, dynamic>;
final code = b["error"] as String?;
final msg = b["message"] as String? ?? "Checkout failed.";
// surface the meaningful ones the backend can return.
if (resp.statusCode == 451) {
return IapError(msg, code: code ?? "region_blocked");
}
if (resp.statusCode == 409) {
return IapError(msg, code: code ?? "connect_not_ready");
}
return IapError(msg, code: code);
} catch (_) {
return IapError("Checkout failed (${resp.statusCode}).",
code: "checkout_failed");
}
}
}
+166
View File
@@ -0,0 +1,166 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
import "models.dart";
// Offline licence verification. The store signs licences RS256 with its own key
// and publishes the matching public key as a JWKS — we only ever hold the public
// half, so we can verify a licence but never forge one.
//
// We deliberately do NOT pull in a heavy jwt lib here: a JWKS RSA verify is just
// "split the compact token, rebuild the public key from n/e, check the PKCS1v15
// SHA-256 signature over header.payload". pointycastle (the same stack the
// backend signs with) gives us exactly that.
// verifies `token` against `jwks`, then checks the licence is for `expectedApp`
// and `expectedSub` and isnt past exp. throws IapError on any failure so the
// caller can treat "not entitled" uniformly.
GarageLicence verifyLicence(
String token,
Map<String, dynamic> jwks, {
required String expectedApp,
required String expectedSub,
}) {
final parts = token.split(".");
if (parts.length != 3) {
throw IapError("Malformed licence token.", code: "bad_token");
}
final header = _decodeJsonSegment(parts[0]);
final payload = _decodeJsonSegment(parts[1]);
final alg = header["alg"] as String?;
if (alg != "RS256") {
throw IapError("Unexpected licence alg: $alg", code: "bad_alg");
}
final kid = header["kid"] as String?;
final key = _findKey(jwks, kid);
if (key == null) {
// rotation: the licence's kid isnt in the cached jwks. a fresh online fetch
// refreshes the jwks, so this resolves itself next time we're online.
throw IapError("No JWKS key for kid '$kid'.", code: "kid_not_found");
}
final signingInput = utf8.encode("${parts[0]}.${parts[1]}");
final signature = _b64UrlBytes(parts[2]);
if (!_verifyRs256(key, Uint8List.fromList(signingInput), signature)) {
throw IapError("Licence signature failed.", code: "bad_signature");
}
// claims
final sub = payload["sub"] as String?;
final app = payload["app"] as String?;
if (sub == null || sub != expectedSub) {
throw IapError("Licence subject mismatch.", code: "sub_mismatch");
}
if (app == null || app != expectedApp) {
throw IapError("Licence app mismatch.", code: "app_mismatch");
}
final iat = _epoch(payload["iat"]);
final exp = _epoch(payload["exp"]);
if (exp == null) {
throw IapError("Licence has no exp.", code: "no_exp");
}
final products = <LicenceProduct>[];
final rawProducts = payload["products"];
if (rawProducts is List) {
for (final p in rawProducts) {
if (p is Map) {
products.add(LicenceProduct.fromClaim(Map<String, dynamic>.from(p)));
}
}
}
final licence = GarageLicence(
subject: sub,
app: app,
products: products,
issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
expiresAt: exp,
);
// exp check trusts the device clock when offline — an accepted limitation.
if (licence.isExpired) {
throw IapError("Licence expired.", code: "expired");
}
return licence;
}
// ---- key lookup ----
// pull the RSA public key out of the jwks for a given kid. if the licence header
// carried no kid and there's exactly one key, use that.
RSAPublicKey? _findKey(Map<String, dynamic> jwks, String? kid) {
final keys = jwks["keys"];
if (keys is! List || keys.isEmpty) return null;
Map<String, dynamic>? match;
for (final k in keys) {
if (k is! Map) continue;
final m = Map<String, dynamic>.from(k);
if (m["kty"] != "RSA") continue;
if (kid == null || m["kid"] == kid) {
match = m;
break;
}
}
if (match == null) return null;
final nB = match["n"] as String?;
final eB = match["e"] as String?;
if (nB == null || eB == null) return null;
final n = _bytesToBigInt(_b64UrlBytes(nB));
final e = _bytesToBigInt(_b64UrlBytes(eB));
return RSAPublicKey(n, e);
}
bool _verifyRs256(RSAPublicKey key, Uint8List input, Uint8List sig) {
final verifier = Signer("SHA-256/RSA") as RSASigner;
verifier.init(false, PublicKeyParameter<RSAPublicKey>(key));
try {
return verifier.verifySignature(input, RSASignature(sig));
} catch (e) {
// a malformed signature can throw rather than just returning false.
print("garage_iap licence verify threw: $e");
return false;
}
}
// ---- small codec helpers ----
Map<String, dynamic> _decodeJsonSegment(String seg) {
final bytes = _b64UrlBytes(seg);
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
Uint8List _b64UrlBytes(String s) {
// jwt segments are base64url with the padding stripped — put it back.
var out = s.replaceAll("-", "+").replaceAll("_", "/");
final pad = out.length % 4;
if (pad > 0) out = out.padRight(out.length + (4 - pad), "=");
return base64.decode(out);
}
BigInt _bytesToBigInt(List<int> bytes) {
var r = BigInt.zero;
for (final b in bytes) {
r = (r << 8) | BigInt.from(b & 0xff);
}
return r;
}
DateTime? _epoch(Object? v) {
if (v == null) return null;
final n = v is int ? v : int.tryParse("$v");
if (n == null) return null;
return DateTime.fromMillisecondsSinceEpoch(n * 1000, isUtc: true);
}
+91
View File
@@ -0,0 +1,91 @@
import "dart:convert";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// Where the offline licence lives. We cache BOTH the licence JWT and the JWKS
// public key in the same trip so offline verification has everything it needs —
// there's deliberately no "licence but no key" state. Keyed by app slug so two
// apps in the same process dont collide.
//
// Reuses secure storage (same backend garage_auth keeps tokens in) — a licence
// is low-value (public-key verifiable, short lived) but keeping it next to the
// tokens means one consistent place for sdk state.
// the small seam, so tests / odd platforms can swap the backend out.
abstract class LicenceCache {
Future<CachedLicence?> read(String appSlug);
Future<void> write(String appSlug, CachedLicence value);
Future<void> clear(String appSlug);
}
class CachedLicence {
CachedLicence({required this.token, required this.jwks});
// the raw compact licence JWT
final String token;
// the jwks doc as fetched ( {"keys":[...]} )
final Map<String, dynamic> jwks;
Map<String, dynamic> toJson() => {"token": token, "jwks": jwks};
factory CachedLicence.fromJson(Map<String, dynamic> j) => CachedLicence(
token: j["token"] as String,
jwks: Map<String, dynamic>.from(j["jwks"] as Map),
);
}
class SecureLicenceCache implements LicenceCache {
SecureLicenceCache({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _key(String slug) => "gi.licence.$slug";
@override
Future<CachedLicence?> read(String appSlug) async {
try {
final raw = await _storage.read(key: _key(appSlug));
if (raw == null || raw.isEmpty) return null;
return CachedLicence.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (e) {
print("garage_iap licence cache read failed: $e");
return null;
}
}
@override
Future<void> write(String appSlug, CachedLicence value) async {
try {
await _storage.write(
key: _key(appSlug), value: jsonEncode(value.toJson()));
} catch (e) {
print("garage_iap licence cache write failed: $e");
}
}
@override
Future<void> clear(String appSlug) async {
try {
await _storage.delete(key: _key(appSlug));
} catch (e) {
print("garage_iap licence cache clear failed: $e");
}
}
}
// in-memory, handy for tests or where you explicitly dont want persistence.
class MemoryLicenceCache implements LicenceCache {
final Map<String, CachedLicence> _m = {};
@override
Future<CachedLicence?> read(String appSlug) async => _m[appSlug];
@override
Future<void> write(String appSlug, CachedLicence value) async =>
_m[appSlug] = value;
@override
Future<void> clear(String appSlug) async => _m.remove(appSlug);
}
+162
View File
@@ -0,0 +1,162 @@
// The buyer-facing data shapes. These mirror the store API json (see
// `productJson` / `entitlementJson` in the backend) but only carry what a
// client actually wants — no created_at/updated_at churn, no raw provider blobs.
// how a purchase is taken. sheet = our embedded flutter_stripe sheet; handoff =
// the system browser to hosted checkout. sheet quietly degrades to handoff for
// subscriptions and on platforms with no native stripe sdk.
enum PurchaseMode { sheet, handoff }
// where a purchase ended up. granted means the entitlement landed (the webhook
// fired and we saw it on poll). pending means we finished the user-facing bit
// but the grant hadnt shown up before we gave up waiting — it may still arrive.
enum PurchaseOutcome { granted, pending, canceled }
class GarageProduct {
GarageProduct({
required this.id,
required this.sku,
required this.name,
required this.kind,
required this.priceMinor,
required this.currency,
this.description,
this.providerPriceId,
this.trialDays,
this.active = true,
});
final String id;
final String sku;
final String name;
final String? description;
// 'app' | 'subscription'. consumables are shelved server side.
final String kind;
final String? providerPriceId;
// price in the currency's minor unit (pence, cents...).
final int priceMinor;
final String currency;
final int? trialDays;
final bool active;
bool get isSubscription => kind == "subscription";
bool get isFree => priceMinor <= 0;
bool get hasTrial => (trialDays ?? 0) > 0;
factory GarageProduct.fromJson(Map<String, dynamic> j) {
return GarageProduct(
id: j["id"] as String,
sku: j["sku"] as String,
name: j["name"] as String,
description: j["description"] as String?,
kind: j["kind"] as String? ?? "app",
providerPriceId: j["provider_price_id"] as String?,
priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0,
currency: (j["currency"] as String? ?? "usd"),
trialDays: (j["trial_days"] as num?)?.toInt(),
active: j["active"] == true || j["active"] == 1,
);
}
// a rough display price. no FX, no locale — the storefront does the pretty
// formatting, this is just a sane default for quick UIs.
String get displayPrice {
if (isFree) return "Free";
final major = priceMinor / 100.0;
return "${currency.toUpperCase()} ${major.toStringAsFixed(2)}";
}
}
class GarageEntitlement {
GarageEntitlement({
required this.productId,
required this.kind,
required this.status,
required this.active,
this.appId,
this.source,
this.expiresAt,
});
final String? appId;
final String productId;
final String kind;
// 'active' | 'trialing' | 'expired' | 'refunded' | 'revoked'
final String status;
final String? source;
// null = perpetual.
final DateTime? expiresAt;
// the server's own verdict (status + expiry) — we trust it rather than
// recomputing the rule on the client.
final bool active;
factory GarageEntitlement.fromJson(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
return GarageEntitlement(
appId: j["app_id"] as String?,
productId: j["product_id"] as String? ?? "",
kind: j["kind"] as String? ?? "app",
status: j["status"] as String? ?? "expired",
source: j["source"] as String?,
expiresAt: exp == null || exp.isEmpty ? null : DateTime.tryParse(exp),
active: j["active"] == true,
);
}
}
// a verified, decoded licence. the products list is the sku/kind set the licence
// vouches for; `has` reads off this when offline.
class GarageLicence {
GarageLicence({
required this.subject,
required this.app,
required this.products,
required this.issuedAt,
required this.expiresAt,
});
final String subject; // the user id (`sub`)
final String app; // the app slug
final List<LicenceProduct> products;
final DateTime issuedAt;
final DateTime expiresAt;
bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt);
bool grants(String sku) => products.any((p) => p.sku == sku);
}
class LicenceProduct {
LicenceProduct({required this.sku, required this.kind, this.expiresAt});
final String sku;
final String kind;
final DateTime? expiresAt;
factory LicenceProduct.fromClaim(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
return LicenceProduct(
sku: j["sku"] as String? ?? "",
kind: j["kind"] as String? ?? "app",
expiresAt: exp == null || exp.isEmpty ? null : DateTime.tryParse(exp),
);
}
}
// thrown for the iap-specific failures. network errors from the authed client
// bubble up as-is.
class IapError implements Exception {
IapError(this.message, {this.code});
final String message;
final String? code;
@override
String toString() => code == null ? message : "$message ($code)";
}
+35
View File
@@ -0,0 +1,35 @@
// The embedded purchase sheet seam. flutter_stripe only has a native sheet on
// iOS / Android (and web has its own thing), so the actual impl is conditionally
// imported — native pulls in flutter_stripe, everything else gets a stub that
// reports "unsupported" and lets GarageIap fall back to the browser handoff.
export "sheet_stub.dart" if (dart.library.io) "sheet_io.dart";
// the result of trying to present the sheet.
// completed — the user paid (sheet closed on success). poll entitlements.
// canceled — the user dismissed it.
// unsupported— no native sheet on this platform; caller should handoff.
enum SheetResult { completed, canceled, unsupported }
// what the sheet needs to init + present. mirrors the payment-intent response.
class SheetParams {
SheetParams({
required this.clientSecret,
required this.publishableKey,
this.stripeAccount,
this.customer,
this.ephemeralKey,
this.merchantName = "Garage",
});
final String clientSecret;
final String publishableKey;
// set for third-party (direct charge) sales — confirm on the connected acct.
final String? stripeAccount;
final String? customer;
final String? ephemeralKey;
final String merchantName;
}
+49
View File
@@ -0,0 +1,49 @@
import "dart:io";
import "package:flutter_stripe/flutter_stripe.dart";
import "sheet.dart";
// Native embedded sheet via flutter_stripe. Only iOS + Android actually carry
// the PaymentSheet — desktop dart:io platforms (linux/macos/windows) report
// unsupported so GarageIap drops to the browser handoff there.
Future<SheetResult> presentSheet(SheetParams params) async {
if (!(Platform.isIOS || Platform.isAndroid)) {
return SheetResult.unsupported;
}
// publishable key drives which stripe account the SDK talks to. for a
// third-party direct charge the PI lives on the connected account, so we set
// stripeAccountId too.
Stripe.publishableKey = params.publishableKey;
if (params.stripeAccount != null && params.stripeAccount!.isNotEmpty) {
Stripe.stripeAccountId = params.stripeAccount;
} else {
Stripe.stripeAccountId = null;
}
await Stripe.instance.applySettings();
try {
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: params.clientSecret,
merchantDisplayName: params.merchantName,
customerId: params.customer,
customerEphemeralKeySecret: params.ephemeralKey,
),
);
await Stripe.instance.presentPaymentSheet();
// present completes without throwing -> payment succeeded (or is processing
// and will be finalised by the webhook). either way we go poll entitlements.
return SheetResult.completed;
} on StripeException catch (e) {
// the user backing out is the common, non-error path.
if (e.error.code == FailureCode.Canceled) {
return SheetResult.canceled;
}
print("garage_iap payment sheet error: ${e.error.localizedMessage}");
rethrow;
}
}
+7
View File
@@ -0,0 +1,7 @@
import "sheet.dart";
// Web / linux / anywhere without a native flutter_stripe sheet. Always reports
// unsupported so GarageIap transparently falls back to the browser handoff.
Future<SheetResult> presentSheet(SheetParams params) async {
return SheetResult.unsupported;
}