From 88e0b48df35156bdd0693471cc367cff3ab82ef1 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 18:54:46 -0400 Subject: [PATCH] fix: harden Play verification and acknowledgement --- .../src/play-verification.test.ts | 114 ++++++++- .../src/play-verification.ts | 231 ++++++++++++++++-- .../purchase_verification_service.dart | 20 +- .../entitlements/entitlement_coordinator.dart | 19 +- .../entitlements/entitlement_state.dart | 2 + test/billing_lifecycle_test.dart | 122 +++++++++ test/purchase_verification_service_test.dart | 87 +++++++ 7 files changed, 572 insertions(+), 23 deletions(-) diff --git a/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts b/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts index 33ce30a..4b85302 100644 --- a/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts +++ b/cloudflare/motorsignal-r2-worker/src/play-verification.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import worker, { type Env } from './index'; -import { ALLOWED_PRODUCTS } from './play-verification'; +import { + ALLOWED_PRODUCTS, + extractLatestExpiry, + acknowledgeSubscription, + acknowledgeOneTimeProduct, +} from './play-verification'; describe('Worker /billing/verify-play-purchase endpoint', () => { let mockEnv: Env; @@ -112,3 +117,110 @@ describe('Worker /billing/verify-play-purchase endpoint', () => { expect(body.code).toBe('GOOGLE_TEMPORARILY_UNAVAILABLE'); }); }); + +describe('extractLatestExpiry', () => { + it('handles empty or undefined line items', () => { + expect(extractLatestExpiry(undefined, 'motorsignal_pro_monthly')).toEqual({ + expiryTimeIso: null, + expiryTimeMs: NaN, + }); + expect(extractLatestExpiry([], 'motorsignal_pro_monthly')).toEqual({ + expiryTimeIso: null, + expiryTimeMs: NaN, + }); + }); + + it('filters out non-matching products and selects latest expiry', () => { + const lineItems = [ + { productId: 'other_tier', expiryTime: '2026-12-31T00:00:00Z' }, + { productId: 'motorsignal_pro_monthly', expiryTime: '2026-06-01T12:00:00Z' }, + { productId: 'motorsignal_pro_monthly', expiryTime: '2026-07-01T12:00:00Z' }, + { productId: 'motorsignal_pro_monthly', expiryTime: 'invalid-date' }, + ]; + + const result = extractLatestExpiry(lineItems, 'motorsignal_pro_monthly'); + expect(result.expiryTimeIso).toBe('2026-07-01T12:00:00.000Z'); + expect(result.expiryTimeMs).toBe(Date.parse('2026-07-01T12:00:00Z')); + }); +}); + +describe('acknowledgeSubscription and acknowledgeOneTimeProduct', () => { + it('acknowledgeSubscription succeeds on 200/204', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + try { + const result = await acknowledgeSubscription( + 'app.motorsignal.motorcycle', + 'motorsignal_pro_monthly', + 'token_123', + 'access_token', + 'hash123' + ); + expect(result.success).toBe(true); + expect(result.isRetryable).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('acknowledgeSubscription handles idempotency on 400 already acknowledged', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Purchase already acknowledged' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ); + try { + const result = await acknowledgeSubscription( + 'app.motorsignal.motorcycle', + 'motorsignal_pro_monthly', + 'token_123', + 'access_token', + 'hash123' + ); + expect(result.success).toBe(true); + expect(result.isRetryable).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('acknowledgeSubscription flags 500 error as retryable', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response('Internal Server Error', { status: 500 }) + ); + try { + const result = await acknowledgeSubscription( + 'app.motorsignal.motorcycle', + 'motorsignal_pro_monthly', + 'token_123', + 'access_token', + 'hash123' + ); + expect(result.success).toBe(false); + expect(result.isRetryable).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('acknowledgeOneTimeProduct succeeds on 200', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + try { + const result = await acknowledgeOneTimeProduct( + 'app.motorsignal.motorcycle', + 'motorsignal_pro_lifetime', + 'token_123', + 'access_token', + 'hash123' + ); + expect(result.success).toBe(true); + expect(result.isRetryable).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/cloudflare/motorsignal-r2-worker/src/play-verification.ts b/cloudflare/motorsignal-r2-worker/src/play-verification.ts index bbde6b7..3b1a8eb 100644 --- a/cloudflare/motorsignal-r2-worker/src/play-verification.ts +++ b/cloudflare/motorsignal-r2-worker/src/play-verification.ts @@ -1,7 +1,7 @@ /** - * MotorSignal — Play Purchase Verification Module + * MotorSignal — Play Purchase Verification & Server Acknowledgement Module * Communicates with Google Play Developer API v3 (Android Publisher API) - * to authoritatively verify subscriptions and one-time purchases. + * to authoritatively verify and acknowledge subscriptions and one-time purchases. */ export interface PlayVerificationEnv { @@ -22,6 +22,8 @@ export interface VerifyPurchaseRequest { purchaseToken: string; } +export type AcknowledgementState = 'acknowledged' | 'pendingRetry' | 'notApplicable'; + export interface VerificationResponse { schemaVersion: number; productId: string; @@ -30,11 +32,13 @@ export interface VerificationResponse { | 'proMonthlyActive' | 'proMonthlyGrace' | 'proMonthlyAccountHold' + | 'proMonthlyPaused' | 'proMonthlyExpired' | 'proLifetime' | 'unknown' | 'syncPending'; verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked'; + acknowledgementState: AcknowledgementState; verifiedAt: string; expiresAt: string | null; tokenHash: string; @@ -135,7 +139,7 @@ export async function getGoogleAccessToken( } /** - * Authoritatively verifies a Google Play purchase token. + * Authoritatively verifies and acknowledges a Google Play purchase token. */ export async function verifyPlayPurchase( req: VerifyPurchaseRequest, @@ -180,7 +184,8 @@ export async function verifyPlayPurchase( try { accessToken = await getGoogleAccessToken(clientEmail, privateKey); } catch (err: unknown) { - console.error('[PlayVerification] OAuth token acquisition failed', err); + const tokenPrefix = tokenHash.substring(0, 8); + console.error(`[PlayVerification] [hash=${tokenPrefix}] OAuth token acquisition failed`, err); return { status: 502, body: { @@ -197,6 +202,138 @@ export async function verifyPlayPurchase( } } +/** + * Extracts latest valid expiration timestamp across all line items matching the target product. + */ +export function extractLatestExpiry( + lineItems: Array<{ productId?: string; expiryTime?: string }> | undefined, + targetProductId: string +): { expiryTimeIso: string | null; expiryTimeMs: number } { + if (!lineItems || lineItems.length === 0) { + return { expiryTimeIso: null, expiryTimeMs: NaN }; + } + + let latestMs = NaN; + let latestIso: string | null = null; + + for (const item of lineItems) { + if (item.productId && item.productId !== targetProductId) { + continue; + } + if (item.expiryTime) { + const ms = Date.parse(item.expiryTime); + if (!isNaN(ms) && (isNaN(latestMs) || ms > latestMs)) { + latestMs = ms; + latestIso = new Date(ms).toISOString(); + } + } + } + + return { expiryTimeIso: latestIso, expiryTimeMs: latestMs }; +} + +/** + * Acknowledges a subscription token server-side via purchases.subscriptions.acknowledge. + */ +export async function acknowledgeSubscription( + packageName: string, + subscriptionId: string, + token: string, + accessToken: string, + tokenHash: string +): Promise<{ success: boolean; isRetryable: boolean }> { + const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent( + packageName + )}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent( + token + )}:acknowledge`; + + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (res.ok || res.status === 204) { + return { success: true, isRetryable: false }; + } + + const tokenPrefix = tokenHash.substring(0, 8); + const text = await res.text().catch(() => ''); + + // Idempotency: If already acknowledged, Google may respond with 400 'Purchase already acknowledged' or similar + if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) { + return { success: true, isRetryable: false }; + } + + console.error( + `[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge failed: status=${res.status} body=${text}` + ); + + const isRetryable = res.status === 429 || res.status >= 500; + return { success: false, isRetryable }; + } catch (e) { + const tokenPrefix = tokenHash.substring(0, 8); + console.error(`[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge network exception:`, e); + return { success: false, isRetryable: true }; + } +} + +/** + * Acknowledges a one-time product token server-side via purchases.products.acknowledge. + */ +export async function acknowledgeOneTimeProduct( + packageName: string, + productId: string, + token: string, + accessToken: string, + tokenHash: string +): Promise<{ success: boolean; isRetryable: boolean }> { + const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent( + packageName + )}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent( + token + )}:acknowledge`; + + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (res.ok || res.status === 204) { + return { success: true, isRetryable: false }; + } + + const tokenPrefix = tokenHash.substring(0, 8); + const text = await res.text().catch(() => ''); + + // Idempotency check for product acknowledgement + if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) { + return { success: true, isRetryable: false }; + } + + console.error( + `[PlayVerification] [hash=${tokenPrefix}] Product acknowledge failed: status=${res.status} body=${text}` + ); + + const isRetryable = res.status === 429 || res.status >= 500; + return { success: false, isRetryable }; + } catch (e) { + const tokenPrefix = tokenHash.substring(0, 8); + console.error(`[PlayVerification] [hash=${tokenPrefix}] Product acknowledge network exception:`, e); + return { success: false, isRetryable: true }; + } +} + /** * Verifies subscription using purchases.subscriptionsv2.get */ @@ -226,7 +363,8 @@ async function verifySubscription( } if (res.status === 401 || res.status === 403) { - console.error(`[PlayVerification] Google returned status ${res.status}`); + const tokenPrefix = tokenHash.substring(0, 8); + console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`); return { status: 502, body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' }, @@ -249,15 +387,17 @@ async function verifySubscription( const sub = (await res.json()) as { subscriptionState?: string; + acknowledgementState?: string; lineItems?: Array<{ productId?: string; expiryTime?: string; }>; }; - const lineItem = sub.lineItems?.[0]; - const expiryTimeStr = lineItem?.expiryTime; - const expiryTimeMs = expiryTimeStr ? Date.parse(expiryTimeStr) : NaN; + const { expiryTimeIso, expiryTimeMs } = extractLatestExpiry( + sub.lineItems, + ALLOWED_PRODUCTS.PRO_MONTHLY + ); const nowMs = Date.now(); const state = sub.subscriptionState || 'SUBSCRIPTION_STATE_UNSPECIFIED'; @@ -281,8 +421,13 @@ async function verifySubscription( verificationStatus = 'verified'; // Hold is confirmed verified, but entitlement state will deny pro break; + case 'SUBSCRIPTION_STATE_PAUSED': + status = 'proMonthlyPaused'; + verificationStatus = 'verified'; // Explicit Paused state; denies Pro access + break; + case 'SUBSCRIPTION_STATE_CANCELED': - // Canceled: user keeps entitlement until current period expires + // Canceled: user keeps entitlement until paid period expires if (!isNaN(expiryTimeMs) && expiryTimeMs > nowMs) { status = 'proMonthlyActive'; verificationStatus = 'verified'; @@ -297,11 +442,6 @@ async function verifySubscription( verificationStatus = 'expired'; break; - case 'SUBSCRIPTION_STATE_PAUSED': - status = 'unknown'; - verificationStatus = 'unverified'; - break; - case 'SUBSCRIPTION_STATE_PENDING': status = 'syncPending'; verificationStatus = 'unverified'; @@ -313,6 +453,38 @@ async function verifySubscription( break; } + // Server-Side Acknowledgement + // Only acknowledge if purchase is in active/entitled state and not already acknowledged. + let ackState: AcknowledgementState = 'notApplicable'; + const isAlreadyAcknowledged = + sub.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'; + + const shouldAcknowledge = + (status === 'proMonthlyActive' || + status === 'proMonthlyGrace' || + status === 'proMonthlyAccountHold') && + verificationStatus === 'verified'; + + if (shouldAcknowledge) { + if (isAlreadyAcknowledged) { + ackState = 'acknowledged'; + } else { + const ackResult = await acknowledgeSubscription( + packageName, + ALLOWED_PRODUCTS.PRO_MONTHLY, + token, + accessToken, + tokenHash + ); + if (ackResult.success) { + ackState = 'acknowledged'; + } else { + // Verification succeeded, but acknowledgement requires retry + ackState = 'pendingRetry'; + } + } + } + return { status: 200, body: { @@ -321,8 +493,9 @@ async function verifySubscription( plan, status, verificationStatus, + acknowledgementState: ackState, verifiedAt: new Date().toISOString(), - expiresAt: !isNaN(expiryTimeMs) ? new Date(expiryTimeMs).toISOString() : null, + expiresAt: expiryTimeIso, tokenHash, }, }; @@ -358,6 +531,8 @@ async function verifyOneTimeProduct( } if (res.status === 401 || res.status === 403) { + const tokenPrefix = tokenHash.substring(0, 8); + console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`); return { status: 502, body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' }, @@ -381,7 +556,7 @@ async function verifyOneTimeProduct( const prod = (await res.json()) as { purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending consumptionState?: number; - acknowledgementState?: number; + acknowledgementState?: number; // 0 = Yet to be acknowledged, 1 = Acknowledged }; const purchaseState = prod.purchaseState ?? 0; @@ -402,6 +577,29 @@ async function verifyOneTimeProduct( verificationStatus = 'unverified'; } + // Server-Side Acknowledgement for Lifetime + let ackState: AcknowledgementState = 'notApplicable'; + const isAlreadyAcknowledged = prod.acknowledgementState === 1; + + if (purchaseState === 0 && verificationStatus === 'verified') { + if (isAlreadyAcknowledged) { + ackState = 'acknowledged'; + } else { + const ackResult = await acknowledgeOneTimeProduct( + packageName, + productId, + token, + accessToken, + tokenHash + ); + if (ackResult.success) { + ackState = 'acknowledged'; + } else { + ackState = 'pendingRetry'; + } + } + } + return { status: 200, body: { @@ -410,6 +608,7 @@ async function verifyOneTimeProduct( plan: 'proLifetime', status, verificationStatus, + acknowledgementState: ackState, verifiedAt: new Date().toISOString(), expiresAt: null, tokenHash, diff --git a/lib/services/billing/purchase_verification_service.dart b/lib/services/billing/purchase_verification_service.dart index a6d4902..3b0d5fb 100644 --- a/lib/services/billing/purchase_verification_service.dart +++ b/lib/services/billing/purchase_verification_service.dart @@ -22,6 +22,7 @@ class PurchaseVerificationResult { final VerificationFailureType? failureType; final String? errorCode; final String? errorMessage; + final String? acknowledgementState; const PurchaseVerificationResult({ required this.success, @@ -29,10 +30,17 @@ class PurchaseVerificationResult { this.failureType, this.errorCode, this.errorMessage, + this.acknowledgementState, }); - const PurchaseVerificationResult.success(EntitlementState state) - : this(success: true, entitlementState: state); + const PurchaseVerificationResult.success( + EntitlementState state, { + String? acknowledgementState, + }) : this( + success: true, + entitlementState: state, + acknowledgementState: acknowledgementState, + ); const PurchaseVerificationResult.failure({ required VerificationFailureType failureType, @@ -46,6 +54,8 @@ class PurchaseVerificationResult { ); bool get isRetryable => failureType == VerificationFailureType.retryable; + bool get isAcknowledged => acknowledgementState == 'acknowledged'; + bool get isPendingAckRetry => acknowledgementState == 'pendingRetry'; } /// Service that submits unverified StorePurchase tokens to the MotorSignal Cloudflare Worker gateway. @@ -166,6 +176,7 @@ class PurchaseVerificationService { final planStr = json['plan'] as String?; final statusStr = json['status'] as String?; final verificationStatusStr = json['verificationStatus'] as String?; + final ackState = json['acknowledgementState'] as String?; final expiresAtStr = json['expiresAt'] as String?; final tokenHash = json['tokenHash'] as String?; @@ -200,7 +211,10 @@ class PurchaseVerificationService { lastVerifiedAt: DateTime.now(), ); - return PurchaseVerificationResult.success(entitlementState); + return PurchaseVerificationResult.success( + entitlementState, + acknowledgementState: ackState, + ); } catch (e) { return PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, diff --git a/lib/services/entitlements/entitlement_coordinator.dart b/lib/services/entitlements/entitlement_coordinator.dart index 73d77ab..3ba94c5 100644 --- a/lib/services/entitlements/entitlement_coordinator.dart +++ b/lib/services/entitlements/entitlement_coordinator.dart @@ -148,15 +148,28 @@ class EntitlementCoordinator extends ChangeNotifier { final result = await verifier.verifyPurchase(purchase: purchase); if (result.success && result.entitlementState != null) { - // Authoritative verification succeeded -> apply entitlement and acknowledge purchase + // Authoritative verification succeeded -> apply verified entitlement await applyVerifiedEntitlement( result.entitlementState!, completedPurchase: purchase, ); - // Dequeue from durable retry storage + // Acknowledgement handling: + // If server successfully acknowledged purchase (or already acknowledged), dequeue. + // If server verification passed but acknowledgement failed (pendingRetry), + // retain in durable queue to retry server acknowledgement, but user keeps Pro access. if (verificationQueue != null) { - await verificationQueue!.dequeue(purchase.purchaseId); + if (result.isPendingAckRetry) { + debugPrint( + '[EntitlementCoordinator] Verification passed but acknowledgement pending retry for ${purchase.purchaseId}', + ); + await verificationQueue!.recordRetry( + purchase.purchaseId, + backoffDelay: const Duration(minutes: 5), + ); + } else { + await verificationQueue!.dequeue(purchase.purchaseId); + } } } else if (result.failureType == VerificationFailureType.terminal) { debugPrint( diff --git a/lib/services/entitlements/entitlement_state.dart b/lib/services/entitlements/entitlement_state.dart index 3df39f4..7538ea9 100644 --- a/lib/services/entitlements/entitlement_state.dart +++ b/lib/services/entitlements/entitlement_state.dart @@ -9,6 +9,7 @@ enum MotorSignalEntitlementStatus { proMonthlyActive, proMonthlyGrace, proMonthlyAccountHold, + proMonthlyPaused, proMonthlyExpired, proLifetime, unknown, @@ -197,6 +198,7 @@ class EntitlementState { if (status == MotorSignalEntitlementStatus.proMonthlyAccountHold) { return false; } + if (status == MotorSignalEntitlementStatus.proMonthlyPaused) return false; if (status == MotorSignalEntitlementStatus.free) return false; if (status == MotorSignalEntitlementStatus.unknown) return false; diff --git a/test/billing_lifecycle_test.dart b/test/billing_lifecycle_test.dart index 3bd0323..34fb9fa 100644 --- a/test/billing_lifecycle_test.dart +++ b/test/billing_lifecycle_test.dart @@ -1,11 +1,15 @@ +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/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'; +import 'package:motorcycle_dash/services/billing/purchase_verification_service.dart'; import 'package:motorcycle_dash/services/entitlements/entitlement_coordinator.dart'; import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; @@ -258,6 +262,124 @@ void main() { expect(cached.plan, MotorSignalPlan.proLifetime); }, ); + + test( + 'verifyStorePurchase dequeues purchase when result is fully acknowledged', + () async { + final queue = InMemoryVerificationQueue(); + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'schemaVersion': 1, + 'productId': ProductCatalog.proMonthly, + 'plan': 'proMonthly', + 'status': 'proMonthlyActive', + 'verificationStatus': 'verified', + 'acknowledgementState': 'acknowledged', + 'verifiedAt': '2026-09-03T18:00:00.000Z', + 'expiresAt': '2026-10-03T18:00:00.000Z', + 'tokenHash': 'hash_ack', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final verifier = PurchaseVerificationService( + client: mockClient, + workerBaseUrl: 'https://test.worker', + ); + + final coordWithVerifier = EntitlementCoordinator( + billingService: billing, + storage: storage, + verificationService: verifier, + verificationQueue: queue, + ); + addTearDown(coordWithVerifier.dispose); + await coordWithVerifier.initialize(); + + const purchase = StorePurchase( + purchaseId: 'order_ack_1', + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.purchased, + transactionDate: '2026-09-01T12:00:00Z', + purchaseToken: 'token_ack_123', + ); + + await queue.enqueue(purchase); + expect(await queue.loadQueue(), hasLength(1)); + + await coordWithVerifier.verifyStorePurchase(purchase); + + expect(coordWithVerifier.state.isPro, isTrue); + expect(await queue.loadQueue(), isEmpty); + }, + ); + + test( + 'verifyStorePurchase grants Pro but retains queue item with backoff on pendingRetry ack', + () async { + final queue = InMemoryVerificationQueue(); + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'schemaVersion': 1, + 'productId': ProductCatalog.proMonthly, + 'plan': 'proMonthly', + 'status': 'proMonthlyActive', + 'verificationStatus': 'verified', + 'acknowledgementState': 'pendingRetry', + 'verifiedAt': '2026-09-03T18:00:00.000Z', + 'expiresAt': '2026-10-03T18:00:00.000Z', + 'tokenHash': 'hash_pending_ack', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final verifier = PurchaseVerificationService( + client: mockClient, + workerBaseUrl: 'https://test.worker', + ); + + final coordWithVerifier = EntitlementCoordinator( + billingService: billing, + storage: storage, + verificationService: verifier, + verificationQueue: queue, + ); + addTearDown(coordWithVerifier.dispose); + await coordWithVerifier.initialize(); + + const purchase = StorePurchase( + purchaseId: 'order_pending_1', + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.purchased, + transactionDate: '2026-09-01T12:00:00Z', + purchaseToken: 'token_pending_123', + ); + + await queue.enqueue(purchase); + expect(await queue.loadQueue(), hasLength(1)); + + await coordWithVerifier.verifyStorePurchase(purchase); + + // User receives verified Pro access immediately + expect(coordWithVerifier.state.isPro, isTrue); + // But queue item is NOT removed; recorded with retry backoff to finalize server ack + final queueItems = await queue.loadQueue(); + expect(queueItems, hasLength(1)); + expect(queueItems.first.attemptCount, 1); + expect( + queueItems.first.nextRetryAt.isAfter( + DateTime.now().add(const Duration(minutes: 4)), + ), + isTrue, + ); + }, + ); }); group('Riverpod integration & Provider boundaries', () { diff --git a/test/purchase_verification_service_test.dart b/test/purchase_verification_service_test.dart index 43131e4..27cb45a 100644 --- a/test/purchase_verification_service_test.dart +++ b/test/purchase_verification_service_test.dart @@ -95,6 +95,93 @@ void main() { }, ); + test( + 'subscription response with pendingRetry acknowledgementState preserves state with isPendingAckRetry', + () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'schemaVersion': 1, + 'productId': ProductCatalog.proMonthly, + 'plan': 'proMonthly', + 'status': 'proMonthlyActive', + 'verificationStatus': 'verified', + 'acknowledgementState': 'pendingRetry', + '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.acknowledgementState, 'pendingRetry'); + expect(result.isPendingAckRetry, isTrue); + expect(result.isAcknowledged, isFalse); + expect(result.entitlementState!.isPro, isTrue); + }, + ); + + test( + 'paused subscription response parses to proMonthlyPaused and denies Pro', + () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'schemaVersion': 1, + 'productId': ProductCatalog.proMonthly, + 'plan': 'proMonthly', + 'status': 'proMonthlyPaused', + 'verificationStatus': 'verified', + 'acknowledgementState': 'notApplicable', + 'verifiedAt': '2026-09-03T18:00:00.000Z', + 'expiresAt': '2026-10-03T18:00:00.000Z', + 'tokenHash': 'hash_paused', + }), + 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_paused', + ); + + final result = await service.verifyPurchase(purchase: purchase); + expect(result.success, isTrue); + expect( + result.entitlementState!.status, + MotorSignalEntitlementStatus.proMonthlyPaused, + ); + expect(result.entitlementState!.isPro, isFalse); + }, + ); + test( 'successful lifetime purchase parses to lifetime Pro without expiration', () async {