diff --git a/MOTORSIGNAL_CLOUD_ARCHITECTURE.md b/MOTORSIGNAL_CLOUD_ARCHITECTURE.md index 6335cf0..a8f4879 100644 --- a/MOTORSIGNAL_CLOUD_ARCHITECTURE.md +++ b/MOTORSIGNAL_CLOUD_ARCHITECTURE.md @@ -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. + diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index 17b43a2..850cf3e 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -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); diff --git a/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts b/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts new file mode 100644 index 0000000..33ce30a --- /dev/null +++ b/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts @@ -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'); + }); +}); diff --git a/cloudflare/motorsignal-r2-worker/src/play-verification.ts b/cloudflare/motorsignal-r2-worker/src/play-verification.ts new file mode 100644 index 0000000..bbde6b7 --- /dev/null +++ b/cloudflare/motorsignal-r2-worker/src/play-verification.ts @@ -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 { + 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 { + 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(/=+$/, ''); +} diff --git a/lib/services/billing/billing_providers.dart b/lib/services/billing/billing_providers.dart index 42a26fc..e769f10 100644 --- a/lib/services/billing/billing_providers.dart +++ b/lib/services/billing/billing_providers.dart @@ -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((ref) async { final service = ref.watch(billingServiceProvider); return await service.isAvailable(); }); + +/// Provider for server purchase verification service. +final purchaseVerificationServiceProvider = + Provider((ref) { + return PurchaseVerificationService(); + }); + +/// Provider for durable verification queue. +final verificationQueueProvider = Provider((ref) { + return const SecureDurableVerificationQueue(); +}); diff --git a/lib/services/billing/durable_verification_queue.dart b/lib/services/billing/durable_verification_queue.dart new file mode 100644 index 0000000..0bf3536 --- /dev/null +++ b/lib/services/billing/durable_verification_queue.dart @@ -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 toJson() => { + 'purchaseId': purchaseId, + 'productId': productId, + 'purchaseToken': purchaseToken, + 'purchaseTokenHash': purchaseTokenHash, + 'attemptCount': attemptCount, + 'queuedAt': queuedAt.toIso8601String(), + 'nextRetryAt': nextRetryAt.toIso8601String(), + }; + + factory QueuedVerificationItem.fromJson(Map 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> loadQueue(); + Future enqueue(StorePurchase purchase); + Future dequeue(String purchaseId); + Future recordRetry(String purchaseId, {required Duration backoffDelay}); + Future clear(); +} + +/// In-memory queue for deterministic testing. +class InMemoryVerificationQueue implements DurableVerificationQueue { + final Map _items = {}; + + @override + Future> loadQueue() async { + return _items.values.toList(); + } + + @override + Future 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 dequeue(String purchaseId) async { + _items.remove(purchaseId); + } + + @override + Future 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 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> 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.from(item as Map), + ), + ) + .toList(); + } catch (e) { + debugPrint('[SecureVerificationQueue] Failed to load queue: $e'); + return []; + } + } + + @override + Future 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.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 dequeue(String purchaseId) async { + final current = await loadQueue(); + final updated = current.where((i) => i.purchaseId != purchaseId).toList(); + await _persistQueue(updated); + } + + @override + Future 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.from(current); + updated[index] = item.copyWithRetry( + attemptCount: item.attemptCount + 1, + nextRetryAt: DateTime.now().add(backoffDelay), + ); + + await _persistQueue(updated); + } + + @override + Future clear() async { + try { + await _secureStorage.delete(key: _storageKey); + } catch (_) {} + } + + Future _persistQueue(List 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'); + } + } +} diff --git a/lib/services/billing/purchase_verification_service.dart b/lib/services/billing/purchase_verification_service.dart new file mode 100644 index 0000000..a6d4902 --- /dev/null +++ b/lib/services/billing/purchase_verification_service.dart @@ -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 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 = { + '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.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, + ); + } +} diff --git a/lib/services/entitlements/entitlement_coordinator.dart b/lib/services/entitlements/entitlement_coordinator.dart index b5d174f..73d77ab 100644 --- a/lib/services/entitlements/entitlement_coordinator.dart +++ b/lib/services/entitlements/entitlement_coordinator.dart @@ -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>? _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 _unverifiedPurchases = []; - EntitlementCoordinator({required this.billingService, required this.storage}); + EntitlementCoordinator({ + required this.billingService, + required this.storage, + this.verificationService, + this.verificationQueue, + }); EntitlementState get state => _state; List 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 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 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 applyVerifiedEntitlement( diff --git a/lib/services/entitlements/entitlement_providers.dart b/lib/services/entitlements/entitlement_providers.dart index d4141e4..fc62ca6 100644 --- a/lib/services/entitlements/entitlement_providers.dart +++ b/lib/services/entitlements/entitlement_providers.dart @@ -16,7 +16,14 @@ final entitlementCoordinatorProvider = ChangeNotifierProvider((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. diff --git a/test/billing_lifecycle_test.dart b/test/billing_lifecycle_test.dart index c94f269..3bd0323 100644 --- a/test/billing_lifecycle_test.dart +++ b/test/billing_lifecycle_test.dart @@ -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); diff --git a/test/durable_verification_queue_test.dart b/test/durable_verification_queue_test.dart new file mode 100644 index 0000000..b30574e --- /dev/null +++ b/test/durable_verification_queue_test.dart @@ -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); + }); + }); +} diff --git a/test/purchase_verification_service_test.dart b/test/purchase_verification_service_test.dart new file mode 100644 index 0000000..43131e4 --- /dev/null +++ b/test/purchase_verification_service_test.dart @@ -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); + }, + ); + }); +}