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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import worker, { type Env } from './index';
|
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', () => {
|
describe('Worker /billing/verify-play-purchase endpoint', () => {
|
||||||
let mockEnv: Env;
|
let mockEnv: Env;
|
||||||
|
|
@ -112,3 +117,110 @@ describe('Worker /billing/verify-play-purchase endpoint', () => {
|
||||||
expect(body.code).toBe('GOOGLE_TEMPORARILY_UNAVAILABLE');
|
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)
|
* 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 {
|
export interface PlayVerificationEnv {
|
||||||
|
|
@ -22,6 +22,8 @@ export interface VerifyPurchaseRequest {
|
||||||
purchaseToken: string;
|
purchaseToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AcknowledgementState = 'acknowledged' | 'pendingRetry' | 'notApplicable';
|
||||||
|
|
||||||
export interface VerificationResponse {
|
export interface VerificationResponse {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
productId: string;
|
productId: string;
|
||||||
|
|
@ -30,11 +32,13 @@ export interface VerificationResponse {
|
||||||
| 'proMonthlyActive'
|
| 'proMonthlyActive'
|
||||||
| 'proMonthlyGrace'
|
| 'proMonthlyGrace'
|
||||||
| 'proMonthlyAccountHold'
|
| 'proMonthlyAccountHold'
|
||||||
|
| 'proMonthlyPaused'
|
||||||
| 'proMonthlyExpired'
|
| 'proMonthlyExpired'
|
||||||
| 'proLifetime'
|
| 'proLifetime'
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
| 'syncPending';
|
| 'syncPending';
|
||||||
verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked';
|
verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked';
|
||||||
|
acknowledgementState: AcknowledgementState;
|
||||||
verifiedAt: string;
|
verifiedAt: string;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
tokenHash: string;
|
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(
|
export async function verifyPlayPurchase(
|
||||||
req: VerifyPurchaseRequest,
|
req: VerifyPurchaseRequest,
|
||||||
|
|
@ -180,7 +184,8 @@ export async function verifyPlayPurchase(
|
||||||
try {
|
try {
|
||||||
accessToken = await getGoogleAccessToken(clientEmail, privateKey);
|
accessToken = await getGoogleAccessToken(clientEmail, privateKey);
|
||||||
} catch (err: unknown) {
|
} 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 {
|
return {
|
||||||
status: 502,
|
status: 502,
|
||||||
body: {
|
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
|
* Verifies subscription using purchases.subscriptionsv2.get
|
||||||
*/
|
*/
|
||||||
|
|
@ -226,7 +363,8 @@ async function verifySubscription(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
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 {
|
return {
|
||||||
status: 502,
|
status: 502,
|
||||||
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
||||||
|
|
@ -249,15 +387,17 @@ async function verifySubscription(
|
||||||
|
|
||||||
const sub = (await res.json()) as {
|
const sub = (await res.json()) as {
|
||||||
subscriptionState?: string;
|
subscriptionState?: string;
|
||||||
|
acknowledgementState?: string;
|
||||||
lineItems?: Array<{
|
lineItems?: Array<{
|
||||||
productId?: string;
|
productId?: string;
|
||||||
expiryTime?: string;
|
expiryTime?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const lineItem = sub.lineItems?.[0];
|
const { expiryTimeIso, expiryTimeMs } = extractLatestExpiry(
|
||||||
const expiryTimeStr = lineItem?.expiryTime;
|
sub.lineItems,
|
||||||
const expiryTimeMs = expiryTimeStr ? Date.parse(expiryTimeStr) : NaN;
|
ALLOWED_PRODUCTS.PRO_MONTHLY
|
||||||
|
);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
const state = sub.subscriptionState || 'SUBSCRIPTION_STATE_UNSPECIFIED';
|
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
|
verificationStatus = 'verified'; // Hold is confirmed verified, but entitlement state will deny pro
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'SUBSCRIPTION_STATE_PAUSED':
|
||||||
|
status = 'proMonthlyPaused';
|
||||||
|
verificationStatus = 'verified'; // Explicit Paused state; denies Pro access
|
||||||
|
break;
|
||||||
|
|
||||||
case 'SUBSCRIPTION_STATE_CANCELED':
|
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) {
|
if (!isNaN(expiryTimeMs) && expiryTimeMs > nowMs) {
|
||||||
status = 'proMonthlyActive';
|
status = 'proMonthlyActive';
|
||||||
verificationStatus = 'verified';
|
verificationStatus = 'verified';
|
||||||
|
|
@ -297,11 +442,6 @@ async function verifySubscription(
|
||||||
verificationStatus = 'expired';
|
verificationStatus = 'expired';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'SUBSCRIPTION_STATE_PAUSED':
|
|
||||||
status = 'unknown';
|
|
||||||
verificationStatus = 'unverified';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SUBSCRIPTION_STATE_PENDING':
|
case 'SUBSCRIPTION_STATE_PENDING':
|
||||||
status = 'syncPending';
|
status = 'syncPending';
|
||||||
verificationStatus = 'unverified';
|
verificationStatus = 'unverified';
|
||||||
|
|
@ -313,6 +453,38 @@ async function verifySubscription(
|
||||||
break;
|
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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -321,8 +493,9 @@ async function verifySubscription(
|
||||||
plan,
|
plan,
|
||||||
status,
|
status,
|
||||||
verificationStatus,
|
verificationStatus,
|
||||||
|
acknowledgementState: ackState,
|
||||||
verifiedAt: new Date().toISOString(),
|
verifiedAt: new Date().toISOString(),
|
||||||
expiresAt: !isNaN(expiryTimeMs) ? new Date(expiryTimeMs).toISOString() : null,
|
expiresAt: expiryTimeIso,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -358,6 +531,8 @@ async function verifyOneTimeProduct(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
const tokenPrefix = tokenHash.substring(0, 8);
|
||||||
|
console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`);
|
||||||
return {
|
return {
|
||||||
status: 502,
|
status: 502,
|
||||||
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
||||||
|
|
@ -381,7 +556,7 @@ async function verifyOneTimeProduct(
|
||||||
const prod = (await res.json()) as {
|
const prod = (await res.json()) as {
|
||||||
purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending
|
purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending
|
||||||
consumptionState?: number;
|
consumptionState?: number;
|
||||||
acknowledgementState?: number;
|
acknowledgementState?: number; // 0 = Yet to be acknowledged, 1 = Acknowledged
|
||||||
};
|
};
|
||||||
|
|
||||||
const purchaseState = prod.purchaseState ?? 0;
|
const purchaseState = prod.purchaseState ?? 0;
|
||||||
|
|
@ -402,6 +577,29 @@ async function verifyOneTimeProduct(
|
||||||
verificationStatus = 'unverified';
|
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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -410,6 +608,7 @@ async function verifyOneTimeProduct(
|
||||||
plan: 'proLifetime',
|
plan: 'proLifetime',
|
||||||
status,
|
status,
|
||||||
verificationStatus,
|
verificationStatus,
|
||||||
|
acknowledgementState: ackState,
|
||||||
verifiedAt: new Date().toISOString(),
|
verifiedAt: new Date().toISOString(),
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class PurchaseVerificationResult {
|
||||||
final VerificationFailureType? failureType;
|
final VerificationFailureType? failureType;
|
||||||
final String? errorCode;
|
final String? errorCode;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
final String? acknowledgementState;
|
||||||
|
|
||||||
const PurchaseVerificationResult({
|
const PurchaseVerificationResult({
|
||||||
required this.success,
|
required this.success,
|
||||||
|
|
@ -29,10 +30,17 @@ class PurchaseVerificationResult {
|
||||||
this.failureType,
|
this.failureType,
|
||||||
this.errorCode,
|
this.errorCode,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.acknowledgementState,
|
||||||
});
|
});
|
||||||
|
|
||||||
const PurchaseVerificationResult.success(EntitlementState state)
|
const PurchaseVerificationResult.success(
|
||||||
: this(success: true, entitlementState: state);
|
EntitlementState state, {
|
||||||
|
String? acknowledgementState,
|
||||||
|
}) : this(
|
||||||
|
success: true,
|
||||||
|
entitlementState: state,
|
||||||
|
acknowledgementState: acknowledgementState,
|
||||||
|
);
|
||||||
|
|
||||||
const PurchaseVerificationResult.failure({
|
const PurchaseVerificationResult.failure({
|
||||||
required VerificationFailureType failureType,
|
required VerificationFailureType failureType,
|
||||||
|
|
@ -46,6 +54,8 @@ class PurchaseVerificationResult {
|
||||||
);
|
);
|
||||||
|
|
||||||
bool get isRetryable => failureType == VerificationFailureType.retryable;
|
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.
|
/// 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 planStr = json['plan'] as String?;
|
||||||
final statusStr = json['status'] as String?;
|
final statusStr = json['status'] as String?;
|
||||||
final verificationStatusStr = json['verificationStatus'] as String?;
|
final verificationStatusStr = json['verificationStatus'] as String?;
|
||||||
|
final ackState = json['acknowledgementState'] as String?;
|
||||||
final expiresAtStr = json['expiresAt'] as String?;
|
final expiresAtStr = json['expiresAt'] as String?;
|
||||||
final tokenHash = json['tokenHash'] as String?;
|
final tokenHash = json['tokenHash'] as String?;
|
||||||
|
|
||||||
|
|
@ -200,7 +211,10 @@ class PurchaseVerificationService {
|
||||||
lastVerifiedAt: DateTime.now(),
|
lastVerifiedAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return PurchaseVerificationResult.success(entitlementState);
|
return PurchaseVerificationResult.success(
|
||||||
|
entitlementState,
|
||||||
|
acknowledgementState: ackState,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return PurchaseVerificationResult.failure(
|
return PurchaseVerificationResult.failure(
|
||||||
failureType: VerificationFailureType.retryable,
|
failureType: VerificationFailureType.retryable,
|
||||||
|
|
|
||||||
|
|
@ -148,15 +148,28 @@ class EntitlementCoordinator extends ChangeNotifier {
|
||||||
final result = await verifier.verifyPurchase(purchase: purchase);
|
final result = await verifier.verifyPurchase(purchase: purchase);
|
||||||
|
|
||||||
if (result.success && result.entitlementState != null) {
|
if (result.success && result.entitlementState != null) {
|
||||||
// Authoritative verification succeeded -> apply entitlement and acknowledge purchase
|
// Authoritative verification succeeded -> apply verified entitlement
|
||||||
await applyVerifiedEntitlement(
|
await applyVerifiedEntitlement(
|
||||||
result.entitlementState!,
|
result.entitlementState!,
|
||||||
completedPurchase: purchase,
|
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 (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) {
|
} else if (result.failureType == VerificationFailureType.terminal) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ enum MotorSignalEntitlementStatus {
|
||||||
proMonthlyActive,
|
proMonthlyActive,
|
||||||
proMonthlyGrace,
|
proMonthlyGrace,
|
||||||
proMonthlyAccountHold,
|
proMonthlyAccountHold,
|
||||||
|
proMonthlyPaused,
|
||||||
proMonthlyExpired,
|
proMonthlyExpired,
|
||||||
proLifetime,
|
proLifetime,
|
||||||
unknown,
|
unknown,
|
||||||
|
|
@ -197,6 +198,7 @@ class EntitlementState {
|
||||||
if (status == MotorSignalEntitlementStatus.proMonthlyAccountHold) {
|
if (status == MotorSignalEntitlementStatus.proMonthlyAccountHold) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (status == MotorSignalEntitlementStatus.proMonthlyPaused) return false;
|
||||||
if (status == MotorSignalEntitlementStatus.free) return false;
|
if (status == MotorSignalEntitlementStatus.free) return false;
|
||||||
if (status == MotorSignalEntitlementStatus.unknown) 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_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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_models.dart';
|
||||||
import 'package:motorcycle_dash/services/billing/billing_providers.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/durable_verification_queue.dart';
|
||||||
import 'package:motorcycle_dash/services/billing/fake_billing_service.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/play_billing_service.dart';
|
||||||
import 'package:motorcycle_dash/services/billing/product_catalog.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_coordinator.dart';
|
||||||
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
|
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
|
||||||
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
|
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
|
||||||
|
|
@ -258,6 +262,124 @@ void main() {
|
||||||
expect(cached.plan, MotorSignalPlan.proLifetime);
|
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', () {
|
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(
|
test(
|
||||||
'successful lifetime purchase parses to lifetime Pro without expiration',
|
'successful lifetime purchase parses to lifetime Pro without expiration',
|
||||||
() async {
|
() async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue