fix: harden Play verification and acknowledgement
This commit is contained in:
parent
69e16cecfa
commit
88e0b48df3
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue