fix: harden Play verification and acknowledgement

This commit is contained in:
Developer 2026-09-03 18:54:46 -04:00
parent 69e16cecfa
commit 88e0b48df3
7 changed files with 572 additions and 23 deletions

View File

@ -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;
}
});
});

View File

@ -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,

View File

@ -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,

View File

@ -148,16 +148,29 @@ 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) {
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(
'[EntitlementCoordinator] Purchase verification rejected terminally: ${result.errorCode} - ${result.errorMessage}',

View File

@ -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;

View File

@ -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', () {

View File

@ -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 {