feat: add server-verified Play entitlements

This commit is contained in:
Developer 2026-09-03 18:42:36 -04:00
parent 3e4a239876
commit 69e16cecfa
12 changed files with 1619 additions and 13 deletions

View File

@ -637,3 +637,44 @@ Local Ride → Sync Queue → Cloud Backup
```
A cloud outage must not prevent a rider from recording a ride.
---
# Server-Verified Google Play Entitlements (Stage 4B-3)
## Verification Pipeline
```text
Google Play Billing Client
↓
MotorSignal BillingService (PlayBillingService)
↓
Unverified StorePurchase (productId, purchaseToken)
↓
DurableVerificationQueue (FlutterSecureStorage)
↓
PurchaseVerificationService
↓
Cloudflare Worker (POST /billing/verify-play-purchase)
↓ (Authenticated via Service Account JWT assertion)
Google Play Developer API v3 (Android Publisher)
- Subscriptions: purchases.subscriptionsv2.get
- Lifetime: purchases.products.get
↓
Verified Entitlement Payload (Plan, Status, ExpiresAt, TokenHash)
↓
EntitlementCoordinator
↓
Secure Local Entitlement Cache (FlutterSecureStorage)
↓
PlayBillingService.completePurchase() (Google Play Acknowledgement)
```
## Security & Architecture Contracts
1. **Server Authority**: The mobile client never self-asserts its entitlement tier. Entitlement is determined authoritatively by Google Play Developer API responses.
2. **Account Independence**: MotorSignal login is strictly optional. Riders without a MotorSignal account can purchase, verify, restore, and own Pro entitlements. Optional Firebase ID tokens sent in verification requests associate convenience metadata without overriding Google Play ownership.
3. **Secret Isolation**: Google Service Account credentials (`GOOGLE_SERVICE_ACCOUNT_EMAIL`, `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY`) reside exclusively as Cloudflare Worker secrets. They are never compiled into the APK, never committed, and never exposed in logs.
4. **Token Handling**: Raw Google Play purchase tokens are never logged or stored in general application databases (Drift, SharedPreferences). Bounded retry queuing utilizes encrypted `FlutterSecureStorage` and purges tokens upon terminal verification.
5. **Rider Safety Invariant**: Active ride recording and telemetry persistence are strictly isolated from billing verification operations and network outages.

View File

@ -1,12 +1,16 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import {
verifyPlayPurchase,
type PlayVerificationEnv,
type VerifyPurchaseRequest,
} from './play-verification';
/**
* MotorSignal — Cloudflare R2 Worker Gateway
* Secure authorization gateway for MotorSignal telemetry bundle storage.
* Firebase ID tokens are cryptographically verified before any R2 access.
* Secure authorization gateway for MotorSignal telemetry bundle storage & purchase verification.
*/
export interface Env {
export interface Env extends PlayVerificationEnv {
R2_BUCKET: R2Bucket;
FIREBASE_PROJECT_ID: string;
ENVIRONMENT: string;
@ -41,6 +45,33 @@ export default {
return jsonResponse({ status: 'ok', service: 'motorsignal-r2-gateway', project: projectId, environment: env.ENVIRONMENT || 'production' });
}
// -------------------------------------------------------------
// Google Play Purchase Verification Endpoint
// Accessible by both SIGNED-IN and SIGNED-OUT users.
// -------------------------------------------------------------
if (request.method === 'POST' && path === '/billing/verify-play-purchase') {
// Enforce max request size (4KB) to mitigate payload abuse
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 4096) {
return jsonResponse({ error: 'Payload too large', code: 'MALFORMED_REQUEST' }, 413);
}
const body = (await request.json().catch(() => null)) as VerifyPurchaseRequest | null;
if (!body || typeof body !== 'object') {
return jsonResponse({ error: 'Invalid JSON payload', code: 'MALFORMED_REQUEST' }, 400);
}
// Optional Firebase authentication check for account convenience metadata (non-blocking)
const authHeader = request.headers.get('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const optToken = authHeader.substring(7).trim();
await verifyFirebaseToken(optToken, projectId).catch(() => null);
}
const result = await verifyPlayPurchase(body, env);
return jsonResponse(result.body, result.status);
}
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401);

View File

@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import worker, { type Env } from './index';
import { ALLOWED_PRODUCTS } from './play-verification';
describe('Worker /billing/verify-play-purchase endpoint', () => {
let mockEnv: Env;
beforeEach(() => {
mockEnv = {
R2_BUCKET: {} as any,
FIREBASE_PROJECT_ID: 'motoapp-d22e0',
ENVIRONMENT: 'test',
GOOGLE_SERVICE_ACCOUNT_EMAIL: 'test-sa@motoapp-d22e0.iam.gserviceaccount.com',
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3...\n-----END PRIVATE KEY-----',
GOOGLE_PLAY_PACKAGE_NAME: 'app.motorsignal.motorcycle',
};
});
it('rejects malformed or empty JSON payload with 400', async () => {
const req = new Request('https://worker.local/billing/verify-play-purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not-json',
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(400);
const body = await res.json() as any;
expect(body.code).toBe('MALFORMED_REQUEST');
});
it('rejects oversized payload with 413', async () => {
const req = new Request('https://worker.local/billing/verify-play-purchase', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'content-length': '5000',
},
body: JSON.stringify({ productId: 'test', purchaseToken: 'test' }),
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(413);
const body = await res.json() as any;
expect(body.code).toBe('MALFORMED_REQUEST');
});
it('rejects missing or empty productId with 400', async () => {
const req = new Request('https://worker.local/billing/verify-play-purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ purchaseToken: 'some_long_token_here_12345' }),
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(400);
const body = await res.json() as any;
expect(body.code).toBe('INVALID_PRODUCT');
});
it('rejects unrecognized product ID with 400', async () => {
const req = new Request('https://worker.local/billing/verify-play-purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: 'hacked_super_tier',
purchaseToken: 'some_long_token_here_12345',
}),
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(400);
const body = await res.json() as any;
expect(body.code).toBe('INVALID_PRODUCT');
});
it('rejects short or missing purchaseToken with 400', async () => {
const req = new Request('https://worker.local/billing/verify-play-purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
purchaseToken: 'short',
}),
});
const res = await worker.fetch(req, mockEnv);
expect(res.status).toBe(400);
const body = await res.json() as any;
expect(body.code).toBe('INVALID_PURCHASE');
});
it('returns 503 if Google service account credentials are unconfigured', async () => {
const envNoCreds: Env = {
...mockEnv,
GOOGLE_SERVICE_ACCOUNT_EMAIL: undefined,
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: undefined,
};
const req = new Request('https://worker.local/billing/verify-play-purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
purchaseToken: 'valid_looking_purchase_token_12345',
}),
});
const res = await worker.fetch(req, envNoCreds);
expect(res.status).toBe(503);
const body = await res.json() as any;
expect(body.code).toBe('GOOGLE_TEMPORARILY_UNAVAILABLE');
});
});

View File

@ -0,0 +1,447 @@
/**
* MotorSignal — Play Purchase Verification Module
* Communicates with Google Play Developer API v3 (Android Publisher API)
* to authoritatively verify subscriptions and one-time purchases.
*/
export interface PlayVerificationEnv {
GOOGLE_SERVICE_ACCOUNT_EMAIL?: string;
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?: string;
GOOGLE_PLAY_PACKAGE_NAME?: string;
}
export const AUTHORITATIVE_PACKAGE_NAME = 'app.motorsignal.motorcycle';
export const ALLOWED_PRODUCTS = {
PRO_MONTHLY: 'motorsignal_pro_monthly',
PRO_LIFETIME: 'motorsignal_pro_lifetime',
} as const;
export interface VerifyPurchaseRequest {
productId: string;
purchaseToken: string;
}
export interface VerificationResponse {
schemaVersion: number;
productId: string;
plan: 'proMonthly' | 'proLifetime' | 'free';
status:
| 'proMonthlyActive'
| 'proMonthlyGrace'
| 'proMonthlyAccountHold'
| 'proMonthlyExpired'
| 'proLifetime'
| 'unknown'
| 'syncPending';
verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked';
verifiedAt: string;
expiresAt: string | null;
tokenHash: string;
error?: string;
}
// In-memory token cache for short-lived Google OAuth access token
let cachedAccessToken: { token: string; expiresAtMs: number } | null = null;
/**
* Derives SHA-256 fingerprint of purchase token for replay tracking / cache key.
*/
export async function computeTokenHash(token: string): Promise<string> {
const enc = new TextEncoder().encode(token);
const digest = await crypto.subtle.digest('SHA-256', enc);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Generates an OAuth2 access token for Google APIs via a service account private key.
*/
export async function getGoogleAccessToken(
clientEmail: string,
privateKeyPem: string
): Promise<string> {
const nowMs = Date.now();
if (cachedAccessToken && cachedAccessToken.expiresAtMs > nowMs + 60000) {
return cachedAccessToken.token;
}
const nowSec = Math.floor(nowMs / 1000);
const expSec = nowSec + 3600;
const header = {
alg: 'RS256',
typ: 'JWT',
};
const claimSet = {
iss: clientEmail,
scope: 'https://www.googleapis.com/auth/androidpublisher',
aud: 'https://oauth2.googleapis.com/token',
exp: expSec,
iat: nowSec,
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedClaimSet = base64UrlEncode(JSON.stringify(claimSet));
const signingInput = `${encodedHeader}.${encodedClaimSet}`;
const binaryKey = pemToBinary(privateKeyPem);
const cryptoKey = await crypto.subtle.importKey(
'pkcs8',
binaryKey,
{
name: 'RSASSA-PKCS1-v1_5',
hash: { name: 'SHA-256' },
},
false,
['sign']
);
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
cryptoKey,
new TextEncoder().encode(signingInput)
);
const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
const tokenResp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
});
if (!tokenResp.ok) {
const errText = await tokenResp.text().catch(() => '');
throw new Error(`Google OAuth exchange failed (${tokenResp.status}): ${errText}`);
}
const tokenData = (await tokenResp.json()) as {
access_token: string;
expires_in: number;
};
cachedAccessToken = {
token: tokenData.access_token,
expiresAtMs: nowMs + (tokenData.expires_in || 3600) * 1000,
};
return cachedAccessToken.token;
}
/**
* Authoritatively verifies a Google Play purchase token.
*/
export async function verifyPlayPurchase(
req: VerifyPurchaseRequest,
env: PlayVerificationEnv
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
const { productId, purchaseToken } = req;
if (!productId || typeof productId !== 'string') {
return { status: 400, body: { error: 'Invalid or missing productId', code: 'INVALID_PRODUCT' } };
}
if (!purchaseToken || typeof purchaseToken !== 'string' || purchaseToken.length < 10) {
return { status: 400, body: { error: 'Invalid or missing purchaseToken', code: 'INVALID_PURCHASE' } };
}
if (
productId !== ALLOWED_PRODUCTS.PRO_MONTHLY &&
productId !== ALLOWED_PRODUCTS.PRO_LIFETIME
) {
return {
status: 400,
body: { error: `Product '${productId}' is not recognized`, code: 'INVALID_PRODUCT' },
};
}
const tokenHash = await computeTokenHash(purchaseToken);
const packageName = env.GOOGLE_PLAY_PACKAGE_NAME || AUTHORITATIVE_PACKAGE_NAME;
const clientEmail = env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
const privateKey = env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY;
if (!clientEmail || !privateKey) {
return {
status: 503,
body: {
error: 'Google Play verification credentials not configured on server',
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
},
};
}
let accessToken: string;
try {
accessToken = await getGoogleAccessToken(clientEmail, privateKey);
} catch (err: unknown) {
console.error('[PlayVerification] OAuth token acquisition failed', err);
return {
status: 502,
body: {
error: 'Failed to authenticate with Google Developer API',
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
},
};
}
if (productId === ALLOWED_PRODUCTS.PRO_MONTHLY) {
return await verifySubscription(packageName, purchaseToken, tokenHash, accessToken);
} else {
return await verifyOneTimeProduct(packageName, productId, purchaseToken, tokenHash, accessToken);
}
}
/**
* Verifies subscription using purchases.subscriptionsv2.get
*/
async function verifySubscription(
packageName: string,
token: string,
tokenHash: string,
accessToken: string
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
packageName
)}/purchases/subscriptionsv2/tokens/${encodeURIComponent(token)}`;
const res = await fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (res.status === 404) {
return {
status: 404,
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
};
}
if (res.status === 401 || res.status === 403) {
console.error(`[PlayVerification] Google returned status ${res.status}`);
return {
status: 502,
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
if (res.status === 429) {
return {
status: 429,
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
};
}
if (!res.ok) {
return {
status: 502,
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
const sub = (await res.json()) as {
subscriptionState?: string;
lineItems?: Array<{
productId?: string;
expiryTime?: string;
}>;
};
const lineItem = sub.lineItems?.[0];
const expiryTimeStr = lineItem?.expiryTime;
const expiryTimeMs = expiryTimeStr ? Date.parse(expiryTimeStr) : NaN;
const nowMs = Date.now();
const state = sub.subscriptionState || 'SUBSCRIPTION_STATE_UNSPECIFIED';
let plan: 'proMonthly' | 'free' = 'proMonthly';
let status: VerificationResponse['status'] = 'proMonthlyActive';
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
switch (state) {
case 'SUBSCRIPTION_STATE_ACTIVE':
status = 'proMonthlyActive';
verificationStatus = 'verified';
break;
case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD':
status = 'proMonthlyGrace';
verificationStatus = 'verified';
break;
case 'SUBSCRIPTION_STATE_ON_HOLD':
status = 'proMonthlyAccountHold';
verificationStatus = 'verified'; // Hold is confirmed verified, but entitlement state will deny pro
break;
case 'SUBSCRIPTION_STATE_CANCELED':
// Canceled: user keeps entitlement until current period expires
if (!isNaN(expiryTimeMs) && expiryTimeMs > nowMs) {
status = 'proMonthlyActive';
verificationStatus = 'verified';
} else {
status = 'proMonthlyExpired';
verificationStatus = 'expired';
}
break;
case 'SUBSCRIPTION_STATE_EXPIRED':
status = 'proMonthlyExpired';
verificationStatus = 'expired';
break;
case 'SUBSCRIPTION_STATE_PAUSED':
status = 'unknown';
verificationStatus = 'unverified';
break;
case 'SUBSCRIPTION_STATE_PENDING':
status = 'syncPending';
verificationStatus = 'unverified';
break;
default:
status = 'unknown';
verificationStatus = 'unverified';
break;
}
return {
status: 200,
body: {
schemaVersion: 1,
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
plan,
status,
verificationStatus,
verifiedAt: new Date().toISOString(),
expiresAt: !isNaN(expiryTimeMs) ? new Date(expiryTimeMs).toISOString() : null,
tokenHash,
},
};
}
/**
* Verifies one-time lifetime purchase using purchases.products.get
*/
async function verifyOneTimeProduct(
packageName: string,
productId: string,
token: string,
tokenHash: string,
accessToken: string
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
packageName
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(token)}`;
const res = await fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (res.status === 404) {
return {
status: 404,
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
};
}
if (res.status === 401 || res.status === 403) {
return {
status: 502,
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
if (res.status === 429) {
return {
status: 429,
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
};
}
if (!res.ok) {
return {
status: 502,
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
const prod = (await res.json()) as {
purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending
consumptionState?: number;
acknowledgementState?: number;
};
const purchaseState = prod.purchaseState ?? 0;
let status: VerificationResponse['status'] = 'proLifetime';
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
if (purchaseState === 0) {
// Purchased & valid
status = 'proLifetime';
verificationStatus = 'verified';
} else if (purchaseState === 1) {
// Canceled / refunded
status = 'unknown';
verificationStatus = 'revoked';
} else if (purchaseState === 2) {
// Pending
status = 'syncPending';
verificationStatus = 'unverified';
}
return {
status: 200,
body: {
schemaVersion: 1,
productId: ALLOWED_PRODUCTS.PRO_LIFETIME,
plan: 'proLifetime',
status,
verificationStatus,
verifiedAt: new Date().toISOString(),
expiresAt: null,
tokenHash,
},
};
}
// Helpers for PKCS#8 PEM decoding and Base64URL
function pemToBinary(pem: string): ArrayBuffer {
const cleanPem = pem
.replace(/-----BEGIN[ A-Z0-9_-]+-----/g, '')
.replace(/-----END[ A-Z0-9_-]+-----/g, '')
.replace(/\s+/g, '');
const raw = atob(cleanPem);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i);
}
return bytes.buffer;
}
function base64UrlEncode(input: string | ArrayBuffer): string {
let base64: string;
if (typeof input === 'string') {
base64 = btoa(input);
} else {
const bytes = new Uint8Array(input);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
base64 = btoa(binary);
}
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

View File

@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'billing_service.dart';
import 'durable_verification_queue.dart';
import 'play_billing_service.dart';
import 'purchase_verification_service.dart';
/// Provider for the platform [BillingService].
/// Defaults to [PlayBillingService] in production.
@ -16,3 +18,14 @@ final billingAvailabilityProvider = FutureProvider<bool>((ref) async {
final service = ref.watch(billingServiceProvider);
return await service.isAvailable();
});
/// Provider for server purchase verification service.
final purchaseVerificationServiceProvider =
Provider<PurchaseVerificationService>((ref) {
return PurchaseVerificationService();
});
/// Provider for durable verification queue.
final verificationQueueProvider = Provider<DurableVerificationQueue>((ref) {
return const SecureDurableVerificationQueue();
});

View File

@ -0,0 +1,225 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'billing_models.dart';
/// Item in the durable purchase verification queue.
@immutable
class QueuedVerificationItem {
final String purchaseId;
final String productId;
final String purchaseToken;
final String? purchaseTokenHash;
final int attemptCount;
final DateTime queuedAt;
final DateTime nextRetryAt;
const QueuedVerificationItem({
required this.purchaseId,
required this.productId,
required this.purchaseToken,
this.purchaseTokenHash,
this.attemptCount = 0,
required this.queuedAt,
required this.nextRetryAt,
});
QueuedVerificationItem copyWithRetry({
required int attemptCount,
required DateTime nextRetryAt,
}) {
return QueuedVerificationItem(
purchaseId: purchaseId,
productId: productId,
purchaseToken: purchaseToken,
purchaseTokenHash: purchaseTokenHash,
attemptCount: attemptCount,
queuedAt: queuedAt,
nextRetryAt: nextRetryAt,
);
}
Map<String, dynamic> toJson() => {
'purchaseId': purchaseId,
'productId': productId,
'purchaseToken': purchaseToken,
'purchaseTokenHash': purchaseTokenHash,
'attemptCount': attemptCount,
'queuedAt': queuedAt.toIso8601String(),
'nextRetryAt': nextRetryAt.toIso8601String(),
};
factory QueuedVerificationItem.fromJson(Map<String, dynamic> json) {
return QueuedVerificationItem(
purchaseId: json['purchaseId'] as String,
productId: json['productId'] as String,
purchaseToken: json['purchaseToken'] as String,
purchaseTokenHash: json['purchaseTokenHash'] as String?,
attemptCount: json['attemptCount'] as int? ?? 0,
queuedAt: DateTime.parse(json['queuedAt'] as String),
nextRetryAt: DateTime.parse(json['nextRetryAt'] as String),
);
}
}
/// Abstract contract for the durable verification queue.
abstract class DurableVerificationQueue {
Future<List<QueuedVerificationItem>> loadQueue();
Future<void> enqueue(StorePurchase purchase);
Future<void> dequeue(String purchaseId);
Future<void> recordRetry(String purchaseId, {required Duration backoffDelay});
Future<void> clear();
}
/// In-memory queue for deterministic testing.
class InMemoryVerificationQueue implements DurableVerificationQueue {
final Map<String, QueuedVerificationItem> _items = {};
@override
Future<List<QueuedVerificationItem>> loadQueue() async {
return _items.values.toList();
}
@override
Future<void> enqueue(StorePurchase purchase) async {
final token = purchase.purchaseToken;
if (token == null || token.isEmpty) return;
if (!_items.containsKey(purchase.purchaseId)) {
_items[purchase.purchaseId] = QueuedVerificationItem(
purchaseId: purchase.purchaseId,
productId: purchase.productId,
purchaseToken: token,
purchaseTokenHash: purchase.purchaseTokenHash,
queuedAt: DateTime.now(),
nextRetryAt: DateTime.now(),
);
}
}
@override
Future<void> dequeue(String purchaseId) async {
_items.remove(purchaseId);
}
@override
Future<void> recordRetry(
String purchaseId, {
required Duration backoffDelay,
}) async {
final item = _items[purchaseId];
if (item != null) {
_items[purchaseId] = item.copyWithRetry(
attemptCount: item.attemptCount + 1,
nextRetryAt: DateTime.now().add(backoffDelay),
);
}
}
@override
Future<void> clear() async {
_items.clear();
}
}
/// Production implementation backed by FlutterSecureStorage.
/// Isolates raw purchase tokens from general application state.
class SecureDurableVerificationQueue implements DurableVerificationQueue {
static const String _storageKey = 'motorsignal_pending_verification_queue_v1';
final FlutterSecureStorage _secureStorage;
const SecureDurableVerificationQueue({FlutterSecureStorage? secureStorage})
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
@override
Future<List<QueuedVerificationItem>> loadQueue() async {
try {
final raw = await _secureStorage.read(key: _storageKey);
if (raw == null || raw.trim().isEmpty) return [];
final decoded = jsonDecode(raw);
if (decoded is! List) return [];
return decoded
.map(
(item) => QueuedVerificationItem.fromJson(
Map<String, dynamic>.from(item as Map),
),
)
.toList();
} catch (e) {
debugPrint('[SecureVerificationQueue] Failed to load queue: $e');
return [];
}
}
@override
Future<void> enqueue(StorePurchase purchase) async {
final token = purchase.purchaseToken;
if (token == null || token.trim().isEmpty) return;
final current = await loadQueue();
if (current.any((i) => i.purchaseId == purchase.purchaseId)) {
return; // Deduplicate
}
final updated = List<QueuedVerificationItem>.from(current)
..add(
QueuedVerificationItem(
purchaseId: purchase.purchaseId,
productId: purchase.productId,
purchaseToken: token.trim(),
purchaseTokenHash: purchase.purchaseTokenHash,
queuedAt: DateTime.now(),
nextRetryAt: DateTime.now(),
),
);
await _persistQueue(updated);
}
@override
Future<void> dequeue(String purchaseId) async {
final current = await loadQueue();
final updated = current.where((i) => i.purchaseId != purchaseId).toList();
await _persistQueue(updated);
}
@override
Future<void> recordRetry(
String purchaseId, {
required Duration backoffDelay,
}) async {
final current = await loadQueue();
final index = current.indexWhere((i) => i.purchaseId == purchaseId);
if (index == -1) return;
final item = current[index];
final updated = List<QueuedVerificationItem>.from(current);
updated[index] = item.copyWithRetry(
attemptCount: item.attemptCount + 1,
nextRetryAt: DateTime.now().add(backoffDelay),
);
await _persistQueue(updated);
}
@override
Future<void> clear() async {
try {
await _secureStorage.delete(key: _storageKey);
} catch (_) {}
}
Future<void> _persistQueue(List<QueuedVerificationItem> items) async {
try {
if (items.isEmpty) {
await _secureStorage.delete(key: _storageKey);
} else {
final serialized = jsonEncode(items.map((i) => i.toJson()).toList());
await _secureStorage.write(key: _storageKey, value: serialized);
}
} catch (e) {
debugPrint('[SecureVerificationQueue] Failed to persist queue: $e');
}
}
}

View File

@ -0,0 +1,246 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../entitlements/entitlement_state.dart';
import 'billing_models.dart';
import 'product_catalog.dart';
/// Error classifications distinguishing retryable network issues from permanent rejections.
enum VerificationFailureType {
/// Invalid token, invalid product ID, product not owned, or tampered payload.
terminal,
/// Network drop, worker 5xx, Google 5xx, or transient rate limits.
retryable,
}
@immutable
class PurchaseVerificationResult {
final bool success;
final EntitlementState? entitlementState;
final VerificationFailureType? failureType;
final String? errorCode;
final String? errorMessage;
const PurchaseVerificationResult({
required this.success,
this.entitlementState,
this.failureType,
this.errorCode,
this.errorMessage,
});
const PurchaseVerificationResult.success(EntitlementState state)
: this(success: true, entitlementState: state);
const PurchaseVerificationResult.failure({
required VerificationFailureType failureType,
required String errorCode,
required String errorMessage,
}) : this(
success: false,
failureType: failureType,
errorCode: errorCode,
errorMessage: errorMessage,
);
bool get isRetryable => failureType == VerificationFailureType.retryable;
}
/// Service that submits unverified StorePurchase tokens to the MotorSignal Cloudflare Worker gateway.
class PurchaseVerificationService {
static const String defaultWorkerUrl = String.fromEnvironment(
'R2_WORKER_URL',
defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev',
);
final http.Client _client;
final String _workerBaseUrl;
PurchaseVerificationService({http.Client? client, String? workerBaseUrl})
: _client = client ?? http.Client(),
_workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(
RegExp(r'/+$'),
'',
);
String get endpointUrl => '$_workerBaseUrl/billing/verify-play-purchase';
/// Verifies a StorePurchase token against the server.
/// Optional Firebase ID token may be passed if user is signed in, but is NOT required.
Future<PurchaseVerificationResult> verifyPurchase({
required StorePurchase purchase,
String? optionalFirebaseIdToken,
Duration timeout = const Duration(seconds: 25),
}) async {
final token = purchase.purchaseToken;
if (token == null || token.trim().isEmpty) {
return const PurchaseVerificationResult.failure(
failureType: VerificationFailureType.terminal,
errorCode: 'MISSING_TOKEN',
errorMessage: 'Purchase token is missing or empty.',
);
}
if (!ProductCatalog.isValidProduct(purchase.productId)) {
return PurchaseVerificationResult.failure(
failureType: VerificationFailureType.terminal,
errorCode: 'INVALID_PRODUCT',
errorMessage: 'Product ${purchase.productId} is unrecognized.',
);
}
final headers = <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (optionalFirebaseIdToken != null &&
optionalFirebaseIdToken.trim().isNotEmpty) {
headers['Authorization'] = 'Bearer ${optionalFirebaseIdToken.trim()}';
}
final requestBody = jsonEncode({
'productId': purchase.productId,
'purchaseToken': token.trim(),
});
try {
final response = await _client
.post(Uri.parse(endpointUrl), headers: headers, body: requestBody)
.timeout(timeout);
final status = response.statusCode;
if (status >= 200 && status < 300) {
return _parseSuccessResponse(response.body, purchase);
} else if (status >= 400 && status < 500) {
return _parseClientErrorResponse(status, response.body);
} else {
return PurchaseVerificationResult.failure(
failureType: VerificationFailureType.retryable,
errorCode: 'SERVER_ERROR_$status',
errorMessage: 'Server responded with status $status.',
);
}
} on TimeoutException {
return const PurchaseVerificationResult.failure(
failureType: VerificationFailureType.retryable,
errorCode: 'TIMEOUT',
errorMessage: 'Verification request timed out.',
);
} catch (e) {
return PurchaseVerificationResult.failure(
failureType: VerificationFailureType.retryable,
errorCode: 'NETWORK_ERROR',
errorMessage: 'Network exception during verification: $e',
);
}
}
PurchaseVerificationResult _parseSuccessResponse(
String responseBody,
StorePurchase purchase,
) {
try {
final decoded = jsonDecode(responseBody);
if (decoded is! Map) {
return const PurchaseVerificationResult.failure(
failureType: VerificationFailureType.retryable,
errorCode: 'MALFORMED_RESPONSE',
errorMessage: 'Response JSON was not a map.',
);
}
final json = Map<String, dynamic>.from(decoded);
final schemaVersion = json['schemaVersion'] as int? ?? 1;
if (schemaVersion > 1) {
return const PurchaseVerificationResult.failure(
failureType: VerificationFailureType.retryable,
errorCode: 'UNSUPPORTED_SCHEMA',
errorMessage: 'Server response schema version is not supported.',
);
}
final planStr = json['plan'] as String?;
final statusStr = json['status'] as String?;
final verificationStatusStr = json['verificationStatus'] as String?;
final expiresAtStr = json['expiresAt'] as String?;
final tokenHash = json['tokenHash'] as String?;
final plan = MotorSignalPlan.values.firstWhere(
(p) => p.name == planStr,
orElse: () => MotorSignalPlan.free,
);
final status = MotorSignalEntitlementStatus.values.firstWhere(
(s) => s.name == statusStr,
orElse: () => MotorSignalEntitlementStatus.unknown,
);
final verificationStatus = EntitlementVerificationStatus.values
.firstWhere(
(v) => v.name == verificationStatusStr,
orElse: () => EntitlementVerificationStatus.unverified,
);
final expiresAt = expiresAtStr != null
? DateTime.tryParse(expiresAtStr)
: null;
final entitlementState = EntitlementState.normalized(
plan: plan,
status: status,
verificationStatus: verificationStatus,
source: PurchaseSource.googlePlay,
productId: purchase.productId,
purchaseTokenHash: tokenHash ?? purchase.purchaseTokenHash,
expiresAt: expiresAt,
lastVerifiedAt: DateTime.now(),
);
return PurchaseVerificationResult.success(entitlementState);
} catch (e) {
return PurchaseVerificationResult.failure(
failureType: VerificationFailureType.retryable,
errorCode: 'DECODE_ERROR',
errorMessage: 'Failed to decode verification response: $e',
);
}
}
PurchaseVerificationResult _parseClientErrorResponse(
int statusCode,
String responseBody,
) {
String errorCode = 'CLIENT_ERROR_$statusCode';
String errorMessage = 'Verification rejected by server ($statusCode).';
try {
final decoded = jsonDecode(responseBody);
if (decoded is Map) {
final code = decoded['code'] as String?;
final error = decoded['error'] as String?;
if (code != null) errorCode = code;
if (error != null) errorMessage = error;
}
} catch (_) {}
// 429 is rate limited -> retryable
if (statusCode == 429 || errorCode == 'RATE_LIMITED') {
return PurchaseVerificationResult.failure(
failureType: VerificationFailureType.retryable,
errorCode: errorCode,
errorMessage: errorMessage,
);
}
// Other 4xx are terminal rejections
return PurchaseVerificationResult.failure(
failureType: VerificationFailureType.terminal,
errorCode: errorCode,
errorMessage: errorMessage,
);
}
}

View File

@ -2,26 +2,35 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../billing/billing_models.dart';
import '../billing/billing_service.dart';
import '../billing/durable_verification_queue.dart';
import '../billing/product_catalog.dart';
import '../billing/purchase_verification_service.dart';
import 'entitlement_state.dart';
import 'entitlement_storage.dart';
/// Seam coordinating incoming store purchase events with cached entitlement state.
/// Pre-server verification (Stage 4B-2):
/// Observes store purchase events, stages unverified purchases, and preserves verified cache.
/// In Stage 4B-3, server verification will be inserted between detection and final acknowledgement.
/// Stage 4B-3: Authoritatively verifies store purchases via [PurchaseVerificationService]
/// and durable retry queue [DurableVerificationQueue].
class EntitlementCoordinator extends ChangeNotifier {
final BillingService billingService;
final EntitlementStorage storage;
final PurchaseVerificationService? verificationService;
final DurableVerificationQueue? verificationQueue;
StreamSubscription<List<StorePurchase>>? _purchaseSubscription;
EntitlementState _state = const EntitlementState.free();
bool _initialized = false;
bool _isProcessingQueue = false;
// Staged store purchases observed on client, awaiting server verification (Stage 4B-3).
// Staged store purchases observed on client, awaiting server verification.
final List<StorePurchase> _unverifiedPurchases = [];
EntitlementCoordinator({required this.billingService, required this.storage});
EntitlementCoordinator({
required this.billingService,
required this.storage,
this.verificationService,
this.verificationQueue,
});
EntitlementState get state => _state;
List<StorePurchase> get unverifiedPurchases =>
@ -45,6 +54,9 @@ class EntitlementCoordinator extends ChangeNotifier {
);
}
// 3. Process any pending durable verification queue items from prior restarts
unawaited(processPendingVerificationQueue());
_initialized = true;
}
@ -83,15 +95,14 @@ class EntitlementCoordinator extends ChangeNotifier {
}
void _handlePurchasedOrRestored(StorePurchase purchase) {
// Stage 4B-2 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED.
// We register the unverified purchase in the queue for Stage 4B-3 server verification.
// Stage 4B-2/4B-3 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED.
// We register the unverified purchase in memory.
_unverifiedPurchases.removeWhere(
(p) => p.purchaseId == purchase.purchaseId,
);
_unverifiedPurchases.add(purchase);
// If existing state is already valid Pro, preserve it.
// If not verified yet, transition status to syncPending.
// If existing state is not already valid Pro, transition status to syncPending.
if (!_state.isPro) {
final plan = ProductCatalog.isLifetime(purchase.productId)
? MotorSignalPlan.proLifetime
@ -106,6 +117,16 @@ class EntitlementCoordinator extends ChangeNotifier {
);
notifyListeners();
}
// Persist to durable verification queue to survive crashes / network drops
if (verificationQueue != null && purchase.purchaseToken != null) {
verificationQueue!.enqueue(purchase);
}
// Trigger immediate verification attempt
if (verificationService != null) {
unawaited(verifyStorePurchase(purchase));
}
}
void _handlePending(StorePurchase purchase) {
@ -119,6 +140,88 @@ class EntitlementCoordinator extends ChangeNotifier {
}
}
/// Submits an observed store purchase to the server verification endpoint.
Future<void> verifyStorePurchase(StorePurchase purchase) async {
final verifier = verificationService;
if (verifier == null) return;
final result = await verifier.verifyPurchase(purchase: purchase);
if (result.success && result.entitlementState != null) {
// Authoritative verification succeeded -> apply entitlement and acknowledge purchase
await applyVerifiedEntitlement(
result.entitlementState!,
completedPurchase: purchase,
);
// Dequeue from durable retry storage
if (verificationQueue != null) {
await verificationQueue!.dequeue(purchase.purchaseId);
}
} else if (result.failureType == VerificationFailureType.terminal) {
debugPrint(
'[EntitlementCoordinator] Purchase verification rejected terminally: ${result.errorCode} - ${result.errorMessage}',
);
// Terminal failure -> do not unlock Pro, dequeue to stop retrying
_unverifiedPurchases.removeWhere(
(p) => p.purchaseId == purchase.purchaseId,
);
if (!_state.isPro) {
_state = const EntitlementState.free();
notifyListeners();
}
if (verificationQueue != null) {
await verificationQueue!.dequeue(purchase.purchaseId);
}
} else {
debugPrint(
'[EntitlementCoordinator] Purchase verification failed retryably: ${result.errorCode}',
);
// Retryable failure -> record retry in durable queue with exponential backoff
if (verificationQueue != null) {
await verificationQueue!.recordRetry(
purchase.purchaseId,
backoffDelay: const Duration(seconds: 15),
);
}
}
}
/// Processes queued verification items persisted across restarts.
Future<void> processPendingVerificationQueue() async {
final queue = verificationQueue;
final verifier = verificationService;
if (queue == null || verifier == null || _isProcessingQueue) return;
_isProcessingQueue = true;
try {
final items = await queue.loadQueue();
final now = DateTime.now();
for (final item in items) {
if (now.isBefore(item.nextRetryAt)) continue;
final syntheticPurchase = StorePurchase(
purchaseId: item.purchaseId,
productId: item.productId,
status: StorePurchaseStatus.purchased,
transactionDate: item.queuedAt.toIso8601String(),
purchaseToken: item.purchaseToken,
purchaseTokenHash: item.purchaseTokenHash,
pendingCompletePurchase: true,
);
await verifyStorePurchase(syntheticPurchase);
}
} catch (e) {
debugPrint('[EntitlementCoordinator] Queue processing error: $e');
} finally {
_isProcessingQueue = false;
}
}
/// Injected completion helper for testing and Stage 4B-3 verification callback.
/// When server verification confirms the purchase, the coordinator completes it and writes to durable storage.
Future<void> applyVerifiedEntitlement(

View File

@ -16,7 +16,14 @@ final entitlementCoordinatorProvider =
ChangeNotifierProvider<EntitlementCoordinator>((ref) {
final billing = ref.watch(billingServiceProvider);
final storage = ref.watch(entitlementStorageProvider);
return EntitlementCoordinator(billingService: billing, storage: storage);
final verifier = ref.watch(purchaseVerificationServiceProvider);
final queue = ref.watch(verificationQueueProvider);
return EntitlementCoordinator(
billingService: billing,
storage: storage,
verificationService: verifier,
verificationQueue: queue,
);
});
/// Authoritative entitlement state provider.

View File

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/services/billing/billing_models.dart';
import 'package:motorcycle_dash/services/billing/billing_providers.dart';
import 'package:motorcycle_dash/services/billing/durable_verification_queue.dart';
import 'package:motorcycle_dash/services/billing/fake_billing_service.dart';
import 'package:motorcycle_dash/services/billing/play_billing_service.dart';
import 'package:motorcycle_dash/services/billing/product_catalog.dart';
@ -270,6 +271,7 @@ void main() {
overrides: [
billingServiceProvider.overrideWithValue(fakeBilling),
entitlementStorageProvider.overrideWithValue(storage),
verificationQueueProvider.overrideWithValue(InMemoryVerificationQueue()),
],
);
addTearDown(container.dispose);

View File

@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/billing/billing_models.dart';
import 'package:motorcycle_dash/services/billing/durable_verification_queue.dart';
import 'package:motorcycle_dash/services/billing/product_catalog.dart';
void main() {
group('InMemoryVerificationQueue', () {
test('enqueues, dequeues, and records backoff properly', () async {
final queue = InMemoryVerificationQueue();
expect(await queue.loadQueue(), isEmpty);
const purchase = StorePurchase(
purchaseId: 'order_1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'raw_token_secret_123',
purchaseTokenHash: 'hash_123',
);
await queue.enqueue(purchase);
var items = await queue.loadQueue();
expect(items.length, 1);
expect(items.first.purchaseId, 'order_1');
expect(items.first.productId, ProductCatalog.proMonthly);
expect(items.first.purchaseToken, 'raw_token_secret_123');
expect(items.first.attemptCount, 0);
// Duplicate enqueue is idempotent
await queue.enqueue(purchase);
items = await queue.loadQueue();
expect(items.length, 1);
// Record retry
await queue.recordRetry(
'order_1',
backoffDelay: const Duration(seconds: 30),
);
items = await queue.loadQueue();
expect(items.first.attemptCount, 1);
expect(items.first.nextRetryAt.isAfter(items.first.queuedAt), isTrue);
// Dequeue
await queue.dequeue('order_1');
items = await queue.loadQueue();
expect(items, isEmpty);
});
test('ignores purchase with null or empty token', () async {
final queue = InMemoryVerificationQueue();
const purchase = StorePurchase(
purchaseId: 'order_2',
productId: ProductCatalog.proLifetime,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: null,
);
await queue.enqueue(purchase);
expect(await queue.loadQueue(), isEmpty);
});
});
}

View File

@ -0,0 +1,314 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:motorcycle_dash/services/billing/billing_models.dart';
import 'package:motorcycle_dash/services/billing/product_catalog.dart';
import 'package:motorcycle_dash/services/billing/purchase_verification_service.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
void main() {
group('PurchaseVerificationService', () {
test('missing or empty purchase token fails terminally', () async {
final service = PurchaseVerificationService();
const purchase = StorePurchase(
purchaseId: 'p1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: '',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isFalse);
expect(result.failureType, VerificationFailureType.terminal);
expect(result.errorCode, 'MISSING_TOKEN');
});
test('unrecognized product ID fails terminally', () async {
final service = PurchaseVerificationService();
const purchase = StorePurchase(
purchaseId: 'p1',
productId: 'hacked_tier',
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_123',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isFalse);
expect(result.failureType, VerificationFailureType.terminal);
expect(result.errorCode, 'INVALID_PRODUCT');
});
test(
'successful monthly subscription response parses to active Pro',
() async {
final mockClient = MockClient((request) async {
expect(request.url.path, '/billing/verify-play-purchase');
final body = jsonDecode(request.body) as Map;
expect(body['productId'], ProductCatalog.proMonthly);
expect(body['purchaseToken'], 'token_valid_123');
return http.Response(
jsonEncode({
'schemaVersion': 1,
'productId': ProductCatalog.proMonthly,
'plan': 'proMonthly',
'status': 'proMonthlyActive',
'verificationStatus': 'verified',
'verifiedAt': '2026-09-03T18:00:00.000Z',
'expiresAt': '2026-10-03T18:00:00.000Z',
'tokenHash': 'hash_123',
}),
200,
headers: {'content-type': 'application/json'},
);
});
final service = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
const purchase = StorePurchase(
purchaseId: 'p1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_valid_123',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isTrue);
expect(result.entitlementState, isNotNull);
final state = result.entitlementState!;
expect(state.plan, MotorSignalPlan.proMonthly);
expect(state.status, MotorSignalEntitlementStatus.proMonthlyActive);
expect(
state.verificationStatus,
EntitlementVerificationStatus.verified,
);
expect(state.isPro, isTrue);
expect(state.purchaseTokenHash, 'hash_123');
expect(state.expiresAt, DateTime.parse('2026-10-03T18:00:00.000Z'));
},
);
test(
'successful lifetime purchase parses to lifetime Pro without expiration',
() async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'schemaVersion': 1,
'productId': ProductCatalog.proLifetime,
'plan': 'proLifetime',
'status': 'proLifetime',
'verificationStatus': 'verified',
'verifiedAt': '2026-09-03T18:00:00.000Z',
'expiresAt': null,
'tokenHash': 'hash_lifetime',
}),
200,
headers: {'content-type': 'application/json'},
);
});
final service = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
const purchase = StorePurchase(
purchaseId: 'p2',
productId: ProductCatalog.proLifetime,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_lifetime',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isTrue);
expect(result.entitlementState!.plan, MotorSignalPlan.proLifetime);
expect(
result.entitlementState!.status,
MotorSignalEntitlementStatus.proLifetime,
);
expect(result.entitlementState!.expiresAt, isNull);
expect(result.entitlementState!.isPro, isTrue);
},
);
test(
'revoked lifetime purchase parses safely to non-pro revoked status',
() async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'schemaVersion': 1,
'productId': ProductCatalog.proLifetime,
'plan': 'proLifetime',
'status': 'unknown',
'verificationStatus': 'revoked',
'verifiedAt': '2026-09-03T18:00:00.000Z',
'expiresAt': null,
'tokenHash': 'hash_revoked',
}),
200,
headers: {'content-type': 'application/json'},
);
});
final service = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
const purchase = StorePurchase(
purchaseId: 'p2',
productId: ProductCatalog.proLifetime,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_revoked',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isTrue);
expect(result.entitlementState!.isPro, isFalse);
expect(
result.entitlementState!.verificationStatus,
EntitlementVerificationStatus.revoked,
);
},
);
test('client 400 rejection classifies as terminal failure', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'code': 'INVALID_PURCHASE',
'error': 'Purchase token not found in Google Play',
}),
404,
headers: {'content-type': 'application/json'},
);
});
final service = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
const purchase = StorePurchase(
purchaseId: 'p1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'invalid_token',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isFalse);
expect(result.failureType, VerificationFailureType.terminal);
expect(result.errorCode, 'INVALID_PURCHASE');
});
test('rate limited 429 classifies as retryable failure', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({'code': 'RATE_LIMITED', 'error': 'Too many requests'}),
429,
headers: {'content-type': 'application/json'},
);
});
final service = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
const purchase = StorePurchase(
purchaseId: 'p1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_123',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isFalse);
expect(result.failureType, VerificationFailureType.retryable);
expect(result.errorCode, 'RATE_LIMITED');
});
test('worker 502/503 classifies as retryable failure', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'code': 'GOOGLE_TEMPORARILY_UNAVAILABLE',
'error': 'Google service unavailable',
}),
502,
headers: {'content-type': 'application/json'},
);
});
final service = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
const purchase = StorePurchase(
purchaseId: 'p1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_123',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isFalse);
expect(result.failureType, VerificationFailureType.retryable);
expect(result.isRetryable, isTrue);
});
test(
'unsupported future schema version classifies as retryable without unlocking Pro',
() async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'schemaVersion': 99,
'productId': ProductCatalog.proMonthly,
'plan': 'proMonthly',
'status': 'proMonthlyActive',
'verificationStatus': 'verified',
'verifiedAt': '2026-09-03T18:00:00.000Z',
}),
200,
headers: {'content-type': 'application/json'},
);
});
final service = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
const purchase = StorePurchase(
purchaseId: 'p1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_123',
);
final result = await service.verifyPurchase(purchase: purchase);
expect(result.success, isFalse);
expect(result.errorCode, 'UNSUPPORTED_SCHEMA');
expect(result.entitlementState, isNull);
},
);
});
}