feat: add server-verified Play entitlements
This commit is contained in:
parent
3e4a239876
commit
69e16cecfa
|
|
@ -637,3 +637,44 @@ Local Ride → Sync Queue → Cloud Backup
|
||||||
```
|
```
|
||||||
|
|
||||||
A cloud outage must not prevent a rider from recording a ride.
|
A cloud outage must not prevent a rider from recording a ride.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Server-Verified Google Play Entitlements (Stage 4B-3)
|
||||||
|
|
||||||
|
## Verification Pipeline
|
||||||
|
|
||||||
|
```text
|
||||||
|
Google Play Billing Client
|
||||||
|
↓
|
||||||
|
MotorSignal BillingService (PlayBillingService)
|
||||||
|
↓
|
||||||
|
Unverified StorePurchase (productId, purchaseToken)
|
||||||
|
↓
|
||||||
|
DurableVerificationQueue (FlutterSecureStorage)
|
||||||
|
↓
|
||||||
|
PurchaseVerificationService
|
||||||
|
↓
|
||||||
|
Cloudflare Worker (POST /billing/verify-play-purchase)
|
||||||
|
↓ (Authenticated via Service Account JWT assertion)
|
||||||
|
Google Play Developer API v3 (Android Publisher)
|
||||||
|
- Subscriptions: purchases.subscriptionsv2.get
|
||||||
|
- Lifetime: purchases.products.get
|
||||||
|
↓
|
||||||
|
Verified Entitlement Payload (Plan, Status, ExpiresAt, TokenHash)
|
||||||
|
↓
|
||||||
|
EntitlementCoordinator
|
||||||
|
↓
|
||||||
|
Secure Local Entitlement Cache (FlutterSecureStorage)
|
||||||
|
↓
|
||||||
|
PlayBillingService.completePurchase() (Google Play Acknowledgement)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security & Architecture Contracts
|
||||||
|
|
||||||
|
1. **Server Authority**: The mobile client never self-asserts its entitlement tier. Entitlement is determined authoritatively by Google Play Developer API responses.
|
||||||
|
2. **Account Independence**: MotorSignal login is strictly optional. Riders without a MotorSignal account can purchase, verify, restore, and own Pro entitlements. Optional Firebase ID tokens sent in verification requests associate convenience metadata without overriding Google Play ownership.
|
||||||
|
3. **Secret Isolation**: Google Service Account credentials (`GOOGLE_SERVICE_ACCOUNT_EMAIL`, `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY`) reside exclusively as Cloudflare Worker secrets. They are never compiled into the APK, never committed, and never exposed in logs.
|
||||||
|
4. **Token Handling**: Raw Google Play purchase tokens are never logged or stored in general application databases (Drift, SharedPreferences). Bounded retry queuing utilizes encrypted `FlutterSecureStorage` and purges tokens upon terminal verification.
|
||||||
|
5. **Rider Safety Invariant**: Active ride recording and telemetry persistence are strictly isolated from billing verification operations and network outages.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
||||||
|
import {
|
||||||
|
verifyPlayPurchase,
|
||||||
|
type PlayVerificationEnv,
|
||||||
|
type VerifyPurchaseRequest,
|
||||||
|
} from './play-verification';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MotorSignal — Cloudflare R2 Worker Gateway
|
* MotorSignal — Cloudflare R2 Worker Gateway
|
||||||
* Secure authorization gateway for MotorSignal telemetry bundle storage.
|
* Secure authorization gateway for MotorSignal telemetry bundle storage & purchase verification.
|
||||||
* Firebase ID tokens are cryptographically verified before any R2 access.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Env {
|
export interface Env extends PlayVerificationEnv {
|
||||||
R2_BUCKET: R2Bucket;
|
R2_BUCKET: R2Bucket;
|
||||||
FIREBASE_PROJECT_ID: string;
|
FIREBASE_PROJECT_ID: string;
|
||||||
ENVIRONMENT: string;
|
ENVIRONMENT: string;
|
||||||
|
|
@ -41,6 +45,33 @@ export default {
|
||||||
return jsonResponse({ status: 'ok', service: 'motorsignal-r2-gateway', project: projectId, environment: env.ENVIRONMENT || 'production' });
|
return jsonResponse({ status: 'ok', service: 'motorsignal-r2-gateway', project: projectId, environment: env.ENVIRONMENT || 'production' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Google Play Purchase Verification Endpoint
|
||||||
|
// Accessible by both SIGNED-IN and SIGNED-OUT users.
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
if (request.method === 'POST' && path === '/billing/verify-play-purchase') {
|
||||||
|
// Enforce max request size (4KB) to mitigate payload abuse
|
||||||
|
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
|
||||||
|
if (contentLength > 4096) {
|
||||||
|
return jsonResponse({ error: 'Payload too large', code: 'MALFORMED_REQUEST' }, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as VerifyPurchaseRequest | null;
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return jsonResponse({ error: 'Invalid JSON payload', code: 'MALFORMED_REQUEST' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional Firebase authentication check for account convenience metadata (non-blocking)
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
const optToken = authHeader.substring(7).trim();
|
||||||
|
await verifyFirebaseToken(optToken, projectId).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyPlayPurchase(body, env);
|
||||||
|
return jsonResponse(result.body, result.status);
|
||||||
|
}
|
||||||
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401);
|
if (!authHeader || !authHeader.startsWith('Bearer ')) return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import worker, { type Env } from './index';
|
||||||
|
import { ALLOWED_PRODUCTS } from './play-verification';
|
||||||
|
|
||||||
|
describe('Worker /billing/verify-play-purchase endpoint', () => {
|
||||||
|
let mockEnv: Env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEnv = {
|
||||||
|
R2_BUCKET: {} as any,
|
||||||
|
FIREBASE_PROJECT_ID: 'motoapp-d22e0',
|
||||||
|
ENVIRONMENT: 'test',
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_EMAIL: 'test-sa@motoapp-d22e0.iam.gserviceaccount.com',
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3...\n-----END PRIVATE KEY-----',
|
||||||
|
GOOGLE_PLAY_PACKAGE_NAME: 'app.motorsignal.motorcycle',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed or empty JSON payload with 400', async () => {
|
||||||
|
const req = new Request('https://worker.local/billing/verify-play-purchase', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: 'not-json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('MALFORMED_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized payload with 413', async () => {
|
||||||
|
const req = new Request('https://worker.local/billing/verify-play-purchase', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'content-length': '5000',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ productId: 'test', purchaseToken: 'test' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('MALFORMED_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing or empty productId with 400', async () => {
|
||||||
|
const req = new Request('https://worker.local/billing/verify-play-purchase', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ purchaseToken: 'some_long_token_here_12345' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('INVALID_PRODUCT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unrecognized product ID with 400', async () => {
|
||||||
|
const req = new Request('https://worker.local/billing/verify-play-purchase', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId: 'hacked_super_tier',
|
||||||
|
purchaseToken: 'some_long_token_here_12345',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('INVALID_PRODUCT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects short or missing purchaseToken with 400', async () => {
|
||||||
|
const req = new Request('https://worker.local/billing/verify-play-purchase', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
|
||||||
|
purchaseToken: 'short',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await worker.fetch(req, mockEnv);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('INVALID_PURCHASE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 if Google service account credentials are unconfigured', async () => {
|
||||||
|
const envNoCreds: Env = {
|
||||||
|
...mockEnv,
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_EMAIL: undefined,
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = new Request('https://worker.local/billing/verify-play-purchase', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
|
||||||
|
purchaseToken: 'valid_looking_purchase_token_12345',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await worker.fetch(req, envNoCreds);
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
const body = await res.json() as any;
|
||||||
|
expect(body.code).toBe('GOOGLE_TEMPORARILY_UNAVAILABLE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,447 @@
|
||||||
|
/**
|
||||||
|
* MotorSignal — Play Purchase Verification Module
|
||||||
|
* Communicates with Google Play Developer API v3 (Android Publisher API)
|
||||||
|
* to authoritatively verify subscriptions and one-time purchases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PlayVerificationEnv {
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_EMAIL?: string;
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?: string;
|
||||||
|
GOOGLE_PLAY_PACKAGE_NAME?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTHORITATIVE_PACKAGE_NAME = 'app.motorsignal.motorcycle';
|
||||||
|
|
||||||
|
export const ALLOWED_PRODUCTS = {
|
||||||
|
PRO_MONTHLY: 'motorsignal_pro_monthly',
|
||||||
|
PRO_LIFETIME: 'motorsignal_pro_lifetime',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface VerifyPurchaseRequest {
|
||||||
|
productId: string;
|
||||||
|
purchaseToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationResponse {
|
||||||
|
schemaVersion: number;
|
||||||
|
productId: string;
|
||||||
|
plan: 'proMonthly' | 'proLifetime' | 'free';
|
||||||
|
status:
|
||||||
|
| 'proMonthlyActive'
|
||||||
|
| 'proMonthlyGrace'
|
||||||
|
| 'proMonthlyAccountHold'
|
||||||
|
| 'proMonthlyExpired'
|
||||||
|
| 'proLifetime'
|
||||||
|
| 'unknown'
|
||||||
|
| 'syncPending';
|
||||||
|
verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked';
|
||||||
|
verifiedAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
tokenHash: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory token cache for short-lived Google OAuth access token
|
||||||
|
let cachedAccessToken: { token: string; expiresAtMs: number } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives SHA-256 fingerprint of purchase token for replay tracking / cache key.
|
||||||
|
*/
|
||||||
|
export async function computeTokenHash(token: string): Promise<string> {
|
||||||
|
const enc = new TextEncoder().encode(token);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', enc);
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an OAuth2 access token for Google APIs via a service account private key.
|
||||||
|
*/
|
||||||
|
export async function getGoogleAccessToken(
|
||||||
|
clientEmail: string,
|
||||||
|
privateKeyPem: string
|
||||||
|
): Promise<string> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
if (cachedAccessToken && cachedAccessToken.expiresAtMs > nowMs + 60000) {
|
||||||
|
return cachedAccessToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowSec = Math.floor(nowMs / 1000);
|
||||||
|
const expSec = nowSec + 3600;
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
alg: 'RS256',
|
||||||
|
typ: 'JWT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimSet = {
|
||||||
|
iss: clientEmail,
|
||||||
|
scope: 'https://www.googleapis.com/auth/androidpublisher',
|
||||||
|
aud: 'https://oauth2.googleapis.com/token',
|
||||||
|
exp: expSec,
|
||||||
|
iat: nowSec,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||||
|
const encodedClaimSet = base64UrlEncode(JSON.stringify(claimSet));
|
||||||
|
const signingInput = `${encodedHeader}.${encodedClaimSet}`;
|
||||||
|
|
||||||
|
const binaryKey = pemToBinary(privateKeyPem);
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
binaryKey,
|
||||||
|
{
|
||||||
|
name: 'RSASSA-PKCS1-v1_5',
|
||||||
|
hash: { name: 'SHA-256' },
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
'RSASSA-PKCS1-v1_5',
|
||||||
|
cryptoKey,
|
||||||
|
new TextEncoder().encode(signingInput)
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
|
||||||
|
|
||||||
|
const tokenResp = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
assertion: jwt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResp.ok) {
|
||||||
|
const errText = await tokenResp.text().catch(() => '');
|
||||||
|
throw new Error(`Google OAuth exchange failed (${tokenResp.status}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = (await tokenResp.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
cachedAccessToken = {
|
||||||
|
token: tokenData.access_token,
|
||||||
|
expiresAtMs: nowMs + (tokenData.expires_in || 3600) * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return cachedAccessToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritatively verifies a Google Play purchase token.
|
||||||
|
*/
|
||||||
|
export async function verifyPlayPurchase(
|
||||||
|
req: VerifyPurchaseRequest,
|
||||||
|
env: PlayVerificationEnv
|
||||||
|
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
|
||||||
|
const { productId, purchaseToken } = req;
|
||||||
|
|
||||||
|
if (!productId || typeof productId !== 'string') {
|
||||||
|
return { status: 400, body: { error: 'Invalid or missing productId', code: 'INVALID_PRODUCT' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!purchaseToken || typeof purchaseToken !== 'string' || purchaseToken.length < 10) {
|
||||||
|
return { status: 400, body: { error: 'Invalid or missing purchaseToken', code: 'INVALID_PURCHASE' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
productId !== ALLOWED_PRODUCTS.PRO_MONTHLY &&
|
||||||
|
productId !== ALLOWED_PRODUCTS.PRO_LIFETIME
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { error: `Product '${productId}' is not recognized`, code: 'INVALID_PRODUCT' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = await computeTokenHash(purchaseToken);
|
||||||
|
const packageName = env.GOOGLE_PLAY_PACKAGE_NAME || AUTHORITATIVE_PACKAGE_NAME;
|
||||||
|
const clientEmail = env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
||||||
|
const privateKey = env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY;
|
||||||
|
|
||||||
|
if (!clientEmail || !privateKey) {
|
||||||
|
return {
|
||||||
|
status: 503,
|
||||||
|
body: {
|
||||||
|
error: 'Google Play verification credentials not configured on server',
|
||||||
|
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken: string;
|
||||||
|
try {
|
||||||
|
accessToken = await getGoogleAccessToken(clientEmail, privateKey);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('[PlayVerification] OAuth token acquisition failed', err);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
body: {
|
||||||
|
error: 'Failed to authenticate with Google Developer API',
|
||||||
|
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productId === ALLOWED_PRODUCTS.PRO_MONTHLY) {
|
||||||
|
return await verifySubscription(packageName, purchaseToken, tokenHash, accessToken);
|
||||||
|
} else {
|
||||||
|
return await verifyOneTimeProduct(packageName, productId, purchaseToken, tokenHash, accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies subscription using purchases.subscriptionsv2.get
|
||||||
|
*/
|
||||||
|
async function verifySubscription(
|
||||||
|
packageName: string,
|
||||||
|
token: string,
|
||||||
|
tokenHash: string,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
|
||||||
|
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
|
||||||
|
packageName
|
||||||
|
)}/purchases/subscriptionsv2/tokens/${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
console.error(`[PlayVerification] Google returned status ${res.status}`);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
return {
|
||||||
|
status: 429,
|
||||||
|
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = (await res.json()) as {
|
||||||
|
subscriptionState?: string;
|
||||||
|
lineItems?: Array<{
|
||||||
|
productId?: string;
|
||||||
|
expiryTime?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineItem = sub.lineItems?.[0];
|
||||||
|
const expiryTimeStr = lineItem?.expiryTime;
|
||||||
|
const expiryTimeMs = expiryTimeStr ? Date.parse(expiryTimeStr) : NaN;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
const state = sub.subscriptionState || 'SUBSCRIPTION_STATE_UNSPECIFIED';
|
||||||
|
let plan: 'proMonthly' | 'free' = 'proMonthly';
|
||||||
|
let status: VerificationResponse['status'] = 'proMonthlyActive';
|
||||||
|
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'SUBSCRIPTION_STATE_ACTIVE':
|
||||||
|
status = 'proMonthlyActive';
|
||||||
|
verificationStatus = 'verified';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD':
|
||||||
|
status = 'proMonthlyGrace';
|
||||||
|
verificationStatus = 'verified';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SUBSCRIPTION_STATE_ON_HOLD':
|
||||||
|
status = 'proMonthlyAccountHold';
|
||||||
|
verificationStatus = 'verified'; // Hold is confirmed verified, but entitlement state will deny pro
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SUBSCRIPTION_STATE_CANCELED':
|
||||||
|
// Canceled: user keeps entitlement until current period expires
|
||||||
|
if (!isNaN(expiryTimeMs) && expiryTimeMs > nowMs) {
|
||||||
|
status = 'proMonthlyActive';
|
||||||
|
verificationStatus = 'verified';
|
||||||
|
} else {
|
||||||
|
status = 'proMonthlyExpired';
|
||||||
|
verificationStatus = 'expired';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SUBSCRIPTION_STATE_EXPIRED':
|
||||||
|
status = 'proMonthlyExpired';
|
||||||
|
verificationStatus = 'expired';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SUBSCRIPTION_STATE_PAUSED':
|
||||||
|
status = 'unknown';
|
||||||
|
verificationStatus = 'unverified';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SUBSCRIPTION_STATE_PENDING':
|
||||||
|
status = 'syncPending';
|
||||||
|
verificationStatus = 'unverified';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
status = 'unknown';
|
||||||
|
verificationStatus = 'unverified';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
|
||||||
|
plan,
|
||||||
|
status,
|
||||||
|
verificationStatus,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
expiresAt: !isNaN(expiryTimeMs) ? new Date(expiryTimeMs).toISOString() : null,
|
||||||
|
tokenHash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies one-time lifetime purchase using purchases.products.get
|
||||||
|
*/
|
||||||
|
async function verifyOneTimeProduct(
|
||||||
|
packageName: string,
|
||||||
|
productId: string,
|
||||||
|
token: string,
|
||||||
|
tokenHash: string,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
|
||||||
|
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
|
||||||
|
packageName
|
||||||
|
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
return {
|
||||||
|
status: 429,
|
||||||
|
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prod = (await res.json()) as {
|
||||||
|
purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending
|
||||||
|
consumptionState?: number;
|
||||||
|
acknowledgementState?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const purchaseState = prod.purchaseState ?? 0;
|
||||||
|
let status: VerificationResponse['status'] = 'proLifetime';
|
||||||
|
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
|
||||||
|
|
||||||
|
if (purchaseState === 0) {
|
||||||
|
// Purchased & valid
|
||||||
|
status = 'proLifetime';
|
||||||
|
verificationStatus = 'verified';
|
||||||
|
} else if (purchaseState === 1) {
|
||||||
|
// Canceled / refunded
|
||||||
|
status = 'unknown';
|
||||||
|
verificationStatus = 'revoked';
|
||||||
|
} else if (purchaseState === 2) {
|
||||||
|
// Pending
|
||||||
|
status = 'syncPending';
|
||||||
|
verificationStatus = 'unverified';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
productId: ALLOWED_PRODUCTS.PRO_LIFETIME,
|
||||||
|
plan: 'proLifetime',
|
||||||
|
status,
|
||||||
|
verificationStatus,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
expiresAt: null,
|
||||||
|
tokenHash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers for PKCS#8 PEM decoding and Base64URL
|
||||||
|
function pemToBinary(pem: string): ArrayBuffer {
|
||||||
|
const cleanPem = pem
|
||||||
|
.replace(/-----BEGIN[ A-Z0-9_-]+-----/g, '')
|
||||||
|
.replace(/-----END[ A-Z0-9_-]+-----/g, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
const raw = atob(cleanPem);
|
||||||
|
const bytes = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
bytes[i] = raw.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(input: string | ArrayBuffer): string {
|
||||||
|
let base64: string;
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
base64 = btoa(input);
|
||||||
|
} else {
|
||||||
|
const bytes = new Uint8Array(input);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
base64 = btoa(binary);
|
||||||
|
}
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'billing_service.dart';
|
import 'billing_service.dart';
|
||||||
|
import 'durable_verification_queue.dart';
|
||||||
import 'play_billing_service.dart';
|
import 'play_billing_service.dart';
|
||||||
|
import 'purchase_verification_service.dart';
|
||||||
|
|
||||||
/// Provider for the platform [BillingService].
|
/// Provider for the platform [BillingService].
|
||||||
/// Defaults to [PlayBillingService] in production.
|
/// Defaults to [PlayBillingService] in production.
|
||||||
|
|
@ -16,3 +18,14 @@ final billingAvailabilityProvider = FutureProvider<bool>((ref) async {
|
||||||
final service = ref.watch(billingServiceProvider);
|
final service = ref.watch(billingServiceProvider);
|
||||||
return await service.isAvailable();
|
return await service.isAvailable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Provider for server purchase verification service.
|
||||||
|
final purchaseVerificationServiceProvider =
|
||||||
|
Provider<PurchaseVerificationService>((ref) {
|
||||||
|
return PurchaseVerificationService();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider for durable verification queue.
|
||||||
|
final verificationQueueProvider = Provider<DurableVerificationQueue>((ref) {
|
||||||
|
return const SecureDurableVerificationQueue();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'billing_models.dart';
|
||||||
|
|
||||||
|
/// Item in the durable purchase verification queue.
|
||||||
|
@immutable
|
||||||
|
class QueuedVerificationItem {
|
||||||
|
final String purchaseId;
|
||||||
|
final String productId;
|
||||||
|
final String purchaseToken;
|
||||||
|
final String? purchaseTokenHash;
|
||||||
|
final int attemptCount;
|
||||||
|
final DateTime queuedAt;
|
||||||
|
final DateTime nextRetryAt;
|
||||||
|
|
||||||
|
const QueuedVerificationItem({
|
||||||
|
required this.purchaseId,
|
||||||
|
required this.productId,
|
||||||
|
required this.purchaseToken,
|
||||||
|
this.purchaseTokenHash,
|
||||||
|
this.attemptCount = 0,
|
||||||
|
required this.queuedAt,
|
||||||
|
required this.nextRetryAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
QueuedVerificationItem copyWithRetry({
|
||||||
|
required int attemptCount,
|
||||||
|
required DateTime nextRetryAt,
|
||||||
|
}) {
|
||||||
|
return QueuedVerificationItem(
|
||||||
|
purchaseId: purchaseId,
|
||||||
|
productId: productId,
|
||||||
|
purchaseToken: purchaseToken,
|
||||||
|
purchaseTokenHash: purchaseTokenHash,
|
||||||
|
attemptCount: attemptCount,
|
||||||
|
queuedAt: queuedAt,
|
||||||
|
nextRetryAt: nextRetryAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'purchaseId': purchaseId,
|
||||||
|
'productId': productId,
|
||||||
|
'purchaseToken': purchaseToken,
|
||||||
|
'purchaseTokenHash': purchaseTokenHash,
|
||||||
|
'attemptCount': attemptCount,
|
||||||
|
'queuedAt': queuedAt.toIso8601String(),
|
||||||
|
'nextRetryAt': nextRetryAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory QueuedVerificationItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QueuedVerificationItem(
|
||||||
|
purchaseId: json['purchaseId'] as String,
|
||||||
|
productId: json['productId'] as String,
|
||||||
|
purchaseToken: json['purchaseToken'] as String,
|
||||||
|
purchaseTokenHash: json['purchaseTokenHash'] as String?,
|
||||||
|
attemptCount: json['attemptCount'] as int? ?? 0,
|
||||||
|
queuedAt: DateTime.parse(json['queuedAt'] as String),
|
||||||
|
nextRetryAt: DateTime.parse(json['nextRetryAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstract contract for the durable verification queue.
|
||||||
|
abstract class DurableVerificationQueue {
|
||||||
|
Future<List<QueuedVerificationItem>> loadQueue();
|
||||||
|
Future<void> enqueue(StorePurchase purchase);
|
||||||
|
Future<void> dequeue(String purchaseId);
|
||||||
|
Future<void> recordRetry(String purchaseId, {required Duration backoffDelay});
|
||||||
|
Future<void> clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory queue for deterministic testing.
|
||||||
|
class InMemoryVerificationQueue implements DurableVerificationQueue {
|
||||||
|
final Map<String, QueuedVerificationItem> _items = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<QueuedVerificationItem>> loadQueue() async {
|
||||||
|
return _items.values.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> enqueue(StorePurchase purchase) async {
|
||||||
|
final token = purchase.purchaseToken;
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
|
|
||||||
|
if (!_items.containsKey(purchase.purchaseId)) {
|
||||||
|
_items[purchase.purchaseId] = QueuedVerificationItem(
|
||||||
|
purchaseId: purchase.purchaseId,
|
||||||
|
productId: purchase.productId,
|
||||||
|
purchaseToken: token,
|
||||||
|
purchaseTokenHash: purchase.purchaseTokenHash,
|
||||||
|
queuedAt: DateTime.now(),
|
||||||
|
nextRetryAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dequeue(String purchaseId) async {
|
||||||
|
_items.remove(purchaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> recordRetry(
|
||||||
|
String purchaseId, {
|
||||||
|
required Duration backoffDelay,
|
||||||
|
}) async {
|
||||||
|
final item = _items[purchaseId];
|
||||||
|
if (item != null) {
|
||||||
|
_items[purchaseId] = item.copyWithRetry(
|
||||||
|
attemptCount: item.attemptCount + 1,
|
||||||
|
nextRetryAt: DateTime.now().add(backoffDelay),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
_items.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production implementation backed by FlutterSecureStorage.
|
||||||
|
/// Isolates raw purchase tokens from general application state.
|
||||||
|
class SecureDurableVerificationQueue implements DurableVerificationQueue {
|
||||||
|
static const String _storageKey = 'motorsignal_pending_verification_queue_v1';
|
||||||
|
final FlutterSecureStorage _secureStorage;
|
||||||
|
|
||||||
|
const SecureDurableVerificationQueue({FlutterSecureStorage? secureStorage})
|
||||||
|
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<QueuedVerificationItem>> loadQueue() async {
|
||||||
|
try {
|
||||||
|
final raw = await _secureStorage.read(key: _storageKey);
|
||||||
|
if (raw == null || raw.trim().isEmpty) return [];
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! List) return [];
|
||||||
|
return decoded
|
||||||
|
.map(
|
||||||
|
(item) => QueuedVerificationItem.fromJson(
|
||||||
|
Map<String, dynamic>.from(item as Map),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SecureVerificationQueue] Failed to load queue: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> enqueue(StorePurchase purchase) async {
|
||||||
|
final token = purchase.purchaseToken;
|
||||||
|
if (token == null || token.trim().isEmpty) return;
|
||||||
|
|
||||||
|
final current = await loadQueue();
|
||||||
|
if (current.any((i) => i.purchaseId == purchase.purchaseId)) {
|
||||||
|
return; // Deduplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
final updated = List<QueuedVerificationItem>.from(current)
|
||||||
|
..add(
|
||||||
|
QueuedVerificationItem(
|
||||||
|
purchaseId: purchase.purchaseId,
|
||||||
|
productId: purchase.productId,
|
||||||
|
purchaseToken: token.trim(),
|
||||||
|
purchaseTokenHash: purchase.purchaseTokenHash,
|
||||||
|
queuedAt: DateTime.now(),
|
||||||
|
nextRetryAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _persistQueue(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dequeue(String purchaseId) async {
|
||||||
|
final current = await loadQueue();
|
||||||
|
final updated = current.where((i) => i.purchaseId != purchaseId).toList();
|
||||||
|
await _persistQueue(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> recordRetry(
|
||||||
|
String purchaseId, {
|
||||||
|
required Duration backoffDelay,
|
||||||
|
}) async {
|
||||||
|
final current = await loadQueue();
|
||||||
|
final index = current.indexWhere((i) => i.purchaseId == purchaseId);
|
||||||
|
if (index == -1) return;
|
||||||
|
|
||||||
|
final item = current[index];
|
||||||
|
final updated = List<QueuedVerificationItem>.from(current);
|
||||||
|
updated[index] = item.copyWithRetry(
|
||||||
|
attemptCount: item.attemptCount + 1,
|
||||||
|
nextRetryAt: DateTime.now().add(backoffDelay),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _persistQueue(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
try {
|
||||||
|
await _secureStorage.delete(key: _storageKey);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistQueue(List<QueuedVerificationItem> items) async {
|
||||||
|
try {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
await _secureStorage.delete(key: _storageKey);
|
||||||
|
} else {
|
||||||
|
final serialized = jsonEncode(items.map((i) => i.toJson()).toList());
|
||||||
|
await _secureStorage.write(key: _storageKey, value: serialized);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SecureVerificationQueue] Failed to persist queue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../entitlements/entitlement_state.dart';
|
||||||
|
import 'billing_models.dart';
|
||||||
|
import 'product_catalog.dart';
|
||||||
|
|
||||||
|
/// Error classifications distinguishing retryable network issues from permanent rejections.
|
||||||
|
enum VerificationFailureType {
|
||||||
|
/// Invalid token, invalid product ID, product not owned, or tampered payload.
|
||||||
|
terminal,
|
||||||
|
|
||||||
|
/// Network drop, worker 5xx, Google 5xx, or transient rate limits.
|
||||||
|
retryable,
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class PurchaseVerificationResult {
|
||||||
|
final bool success;
|
||||||
|
final EntitlementState? entitlementState;
|
||||||
|
final VerificationFailureType? failureType;
|
||||||
|
final String? errorCode;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const PurchaseVerificationResult({
|
||||||
|
required this.success,
|
||||||
|
this.entitlementState,
|
||||||
|
this.failureType,
|
||||||
|
this.errorCode,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PurchaseVerificationResult.success(EntitlementState state)
|
||||||
|
: this(success: true, entitlementState: state);
|
||||||
|
|
||||||
|
const PurchaseVerificationResult.failure({
|
||||||
|
required VerificationFailureType failureType,
|
||||||
|
required String errorCode,
|
||||||
|
required String errorMessage,
|
||||||
|
}) : this(
|
||||||
|
success: false,
|
||||||
|
failureType: failureType,
|
||||||
|
errorCode: errorCode,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isRetryable => failureType == VerificationFailureType.retryable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service that submits unverified StorePurchase tokens to the MotorSignal Cloudflare Worker gateway.
|
||||||
|
class PurchaseVerificationService {
|
||||||
|
static const String defaultWorkerUrl = String.fromEnvironment(
|
||||||
|
'R2_WORKER_URL',
|
||||||
|
defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev',
|
||||||
|
);
|
||||||
|
|
||||||
|
final http.Client _client;
|
||||||
|
final String _workerBaseUrl;
|
||||||
|
|
||||||
|
PurchaseVerificationService({http.Client? client, String? workerBaseUrl})
|
||||||
|
: _client = client ?? http.Client(),
|
||||||
|
_workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(
|
||||||
|
RegExp(r'/+$'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
String get endpointUrl => '$_workerBaseUrl/billing/verify-play-purchase';
|
||||||
|
|
||||||
|
/// Verifies a StorePurchase token against the server.
|
||||||
|
/// Optional Firebase ID token may be passed if user is signed in, but is NOT required.
|
||||||
|
Future<PurchaseVerificationResult> verifyPurchase({
|
||||||
|
required StorePurchase purchase,
|
||||||
|
String? optionalFirebaseIdToken,
|
||||||
|
Duration timeout = const Duration(seconds: 25),
|
||||||
|
}) async {
|
||||||
|
final token = purchase.purchaseToken;
|
||||||
|
if (token == null || token.trim().isEmpty) {
|
||||||
|
return const PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.terminal,
|
||||||
|
errorCode: 'MISSING_TOKEN',
|
||||||
|
errorMessage: 'Purchase token is missing or empty.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ProductCatalog.isValidProduct(purchase.productId)) {
|
||||||
|
return PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.terminal,
|
||||||
|
errorCode: 'INVALID_PRODUCT',
|
||||||
|
errorMessage: 'Product ${purchase.productId} is unrecognized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final headers = <String, String>{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (optionalFirebaseIdToken != null &&
|
||||||
|
optionalFirebaseIdToken.trim().isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer ${optionalFirebaseIdToken.trim()}';
|
||||||
|
}
|
||||||
|
|
||||||
|
final requestBody = jsonEncode({
|
||||||
|
'productId': purchase.productId,
|
||||||
|
'purchaseToken': token.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.post(Uri.parse(endpointUrl), headers: headers, body: requestBody)
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
final status = response.statusCode;
|
||||||
|
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
return _parseSuccessResponse(response.body, purchase);
|
||||||
|
} else if (status >= 400 && status < 500) {
|
||||||
|
return _parseClientErrorResponse(status, response.body);
|
||||||
|
} else {
|
||||||
|
return PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.retryable,
|
||||||
|
errorCode: 'SERVER_ERROR_$status',
|
||||||
|
errorMessage: 'Server responded with status $status.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on TimeoutException {
|
||||||
|
return const PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.retryable,
|
||||||
|
errorCode: 'TIMEOUT',
|
||||||
|
errorMessage: 'Verification request timed out.',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.retryable,
|
||||||
|
errorCode: 'NETWORK_ERROR',
|
||||||
|
errorMessage: 'Network exception during verification: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PurchaseVerificationResult _parseSuccessResponse(
|
||||||
|
String responseBody,
|
||||||
|
StorePurchase purchase,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(responseBody);
|
||||||
|
if (decoded is! Map) {
|
||||||
|
return const PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.retryable,
|
||||||
|
errorCode: 'MALFORMED_RESPONSE',
|
||||||
|
errorMessage: 'Response JSON was not a map.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final json = Map<String, dynamic>.from(decoded);
|
||||||
|
final schemaVersion = json['schemaVersion'] as int? ?? 1;
|
||||||
|
if (schemaVersion > 1) {
|
||||||
|
return const PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.retryable,
|
||||||
|
errorCode: 'UNSUPPORTED_SCHEMA',
|
||||||
|
errorMessage: 'Server response schema version is not supported.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final planStr = json['plan'] as String?;
|
||||||
|
final statusStr = json['status'] as String?;
|
||||||
|
final verificationStatusStr = json['verificationStatus'] as String?;
|
||||||
|
final expiresAtStr = json['expiresAt'] as String?;
|
||||||
|
final tokenHash = json['tokenHash'] as String?;
|
||||||
|
|
||||||
|
final plan = MotorSignalPlan.values.firstWhere(
|
||||||
|
(p) => p.name == planStr,
|
||||||
|
orElse: () => MotorSignalPlan.free,
|
||||||
|
);
|
||||||
|
|
||||||
|
final status = MotorSignalEntitlementStatus.values.firstWhere(
|
||||||
|
(s) => s.name == statusStr,
|
||||||
|
orElse: () => MotorSignalEntitlementStatus.unknown,
|
||||||
|
);
|
||||||
|
|
||||||
|
final verificationStatus = EntitlementVerificationStatus.values
|
||||||
|
.firstWhere(
|
||||||
|
(v) => v.name == verificationStatusStr,
|
||||||
|
orElse: () => EntitlementVerificationStatus.unverified,
|
||||||
|
);
|
||||||
|
|
||||||
|
final expiresAt = expiresAtStr != null
|
||||||
|
? DateTime.tryParse(expiresAtStr)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final entitlementState = EntitlementState.normalized(
|
||||||
|
plan: plan,
|
||||||
|
status: status,
|
||||||
|
verificationStatus: verificationStatus,
|
||||||
|
source: PurchaseSource.googlePlay,
|
||||||
|
productId: purchase.productId,
|
||||||
|
purchaseTokenHash: tokenHash ?? purchase.purchaseTokenHash,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
lastVerifiedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return PurchaseVerificationResult.success(entitlementState);
|
||||||
|
} catch (e) {
|
||||||
|
return PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.retryable,
|
||||||
|
errorCode: 'DECODE_ERROR',
|
||||||
|
errorMessage: 'Failed to decode verification response: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PurchaseVerificationResult _parseClientErrorResponse(
|
||||||
|
int statusCode,
|
||||||
|
String responseBody,
|
||||||
|
) {
|
||||||
|
String errorCode = 'CLIENT_ERROR_$statusCode';
|
||||||
|
String errorMessage = 'Verification rejected by server ($statusCode).';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(responseBody);
|
||||||
|
if (decoded is Map) {
|
||||||
|
final code = decoded['code'] as String?;
|
||||||
|
final error = decoded['error'] as String?;
|
||||||
|
if (code != null) errorCode = code;
|
||||||
|
if (error != null) errorMessage = error;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 429 is rate limited -> retryable
|
||||||
|
if (statusCode == 429 || errorCode == 'RATE_LIMITED') {
|
||||||
|
return PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.retryable,
|
||||||
|
errorCode: errorCode,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other 4xx are terminal rejections
|
||||||
|
return PurchaseVerificationResult.failure(
|
||||||
|
failureType: VerificationFailureType.terminal,
|
||||||
|
errorCode: errorCode,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,26 +2,35 @@ import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../billing/billing_models.dart';
|
import '../billing/billing_models.dart';
|
||||||
import '../billing/billing_service.dart';
|
import '../billing/billing_service.dart';
|
||||||
|
import '../billing/durable_verification_queue.dart';
|
||||||
import '../billing/product_catalog.dart';
|
import '../billing/product_catalog.dart';
|
||||||
|
import '../billing/purchase_verification_service.dart';
|
||||||
import 'entitlement_state.dart';
|
import 'entitlement_state.dart';
|
||||||
import 'entitlement_storage.dart';
|
import 'entitlement_storage.dart';
|
||||||
|
|
||||||
/// Seam coordinating incoming store purchase events with cached entitlement state.
|
/// Seam coordinating incoming store purchase events with cached entitlement state.
|
||||||
/// Pre-server verification (Stage 4B-2):
|
/// Stage 4B-3: Authoritatively verifies store purchases via [PurchaseVerificationService]
|
||||||
/// Observes store purchase events, stages unverified purchases, and preserves verified cache.
|
/// and durable retry queue [DurableVerificationQueue].
|
||||||
/// In Stage 4B-3, server verification will be inserted between detection and final acknowledgement.
|
|
||||||
class EntitlementCoordinator extends ChangeNotifier {
|
class EntitlementCoordinator extends ChangeNotifier {
|
||||||
final BillingService billingService;
|
final BillingService billingService;
|
||||||
final EntitlementStorage storage;
|
final EntitlementStorage storage;
|
||||||
|
final PurchaseVerificationService? verificationService;
|
||||||
|
final DurableVerificationQueue? verificationQueue;
|
||||||
|
|
||||||
StreamSubscription<List<StorePurchase>>? _purchaseSubscription;
|
StreamSubscription<List<StorePurchase>>? _purchaseSubscription;
|
||||||
EntitlementState _state = const EntitlementState.free();
|
EntitlementState _state = const EntitlementState.free();
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
bool _isProcessingQueue = false;
|
||||||
|
|
||||||
// Staged store purchases observed on client, awaiting server verification (Stage 4B-3).
|
// Staged store purchases observed on client, awaiting server verification.
|
||||||
final List<StorePurchase> _unverifiedPurchases = [];
|
final List<StorePurchase> _unverifiedPurchases = [];
|
||||||
|
|
||||||
EntitlementCoordinator({required this.billingService, required this.storage});
|
EntitlementCoordinator({
|
||||||
|
required this.billingService,
|
||||||
|
required this.storage,
|
||||||
|
this.verificationService,
|
||||||
|
this.verificationQueue,
|
||||||
|
});
|
||||||
|
|
||||||
EntitlementState get state => _state;
|
EntitlementState get state => _state;
|
||||||
List<StorePurchase> get unverifiedPurchases =>
|
List<StorePurchase> get unverifiedPurchases =>
|
||||||
|
|
@ -45,6 +54,9 @@ class EntitlementCoordinator extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Process any pending durable verification queue items from prior restarts
|
||||||
|
unawaited(processPendingVerificationQueue());
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,15 +95,14 @@ class EntitlementCoordinator extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePurchasedOrRestored(StorePurchase purchase) {
|
void _handlePurchasedOrRestored(StorePurchase purchase) {
|
||||||
// Stage 4B-2 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED.
|
// Stage 4B-2/4B-3 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED.
|
||||||
// We register the unverified purchase in the queue for Stage 4B-3 server verification.
|
// We register the unverified purchase in memory.
|
||||||
_unverifiedPurchases.removeWhere(
|
_unverifiedPurchases.removeWhere(
|
||||||
(p) => p.purchaseId == purchase.purchaseId,
|
(p) => p.purchaseId == purchase.purchaseId,
|
||||||
);
|
);
|
||||||
_unverifiedPurchases.add(purchase);
|
_unverifiedPurchases.add(purchase);
|
||||||
|
|
||||||
// If existing state is already valid Pro, preserve it.
|
// If existing state is not already valid Pro, transition status to syncPending.
|
||||||
// If not verified yet, transition status to syncPending.
|
|
||||||
if (!_state.isPro) {
|
if (!_state.isPro) {
|
||||||
final plan = ProductCatalog.isLifetime(purchase.productId)
|
final plan = ProductCatalog.isLifetime(purchase.productId)
|
||||||
? MotorSignalPlan.proLifetime
|
? MotorSignalPlan.proLifetime
|
||||||
|
|
@ -106,6 +117,16 @@ class EntitlementCoordinator extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist to durable verification queue to survive crashes / network drops
|
||||||
|
if (verificationQueue != null && purchase.purchaseToken != null) {
|
||||||
|
verificationQueue!.enqueue(purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger immediate verification attempt
|
||||||
|
if (verificationService != null) {
|
||||||
|
unawaited(verifyStorePurchase(purchase));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePending(StorePurchase purchase) {
|
void _handlePending(StorePurchase purchase) {
|
||||||
|
|
@ -119,6 +140,88 @@ class EntitlementCoordinator extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submits an observed store purchase to the server verification endpoint.
|
||||||
|
Future<void> verifyStorePurchase(StorePurchase purchase) async {
|
||||||
|
final verifier = verificationService;
|
||||||
|
if (verifier == null) return;
|
||||||
|
|
||||||
|
final result = await verifier.verifyPurchase(purchase: purchase);
|
||||||
|
|
||||||
|
if (result.success && result.entitlementState != null) {
|
||||||
|
// Authoritative verification succeeded -> apply entitlement and acknowledge purchase
|
||||||
|
await applyVerifiedEntitlement(
|
||||||
|
result.entitlementState!,
|
||||||
|
completedPurchase: purchase,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dequeue from durable retry storage
|
||||||
|
if (verificationQueue != null) {
|
||||||
|
await verificationQueue!.dequeue(purchase.purchaseId);
|
||||||
|
}
|
||||||
|
} else if (result.failureType == VerificationFailureType.terminal) {
|
||||||
|
debugPrint(
|
||||||
|
'[EntitlementCoordinator] Purchase verification rejected terminally: ${result.errorCode} - ${result.errorMessage}',
|
||||||
|
);
|
||||||
|
// Terminal failure -> do not unlock Pro, dequeue to stop retrying
|
||||||
|
_unverifiedPurchases.removeWhere(
|
||||||
|
(p) => p.purchaseId == purchase.purchaseId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!_state.isPro) {
|
||||||
|
_state = const EntitlementState.free();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationQueue != null) {
|
||||||
|
await verificationQueue!.dequeue(purchase.purchaseId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'[EntitlementCoordinator] Purchase verification failed retryably: ${result.errorCode}',
|
||||||
|
);
|
||||||
|
// Retryable failure -> record retry in durable queue with exponential backoff
|
||||||
|
if (verificationQueue != null) {
|
||||||
|
await verificationQueue!.recordRetry(
|
||||||
|
purchase.purchaseId,
|
||||||
|
backoffDelay: const Duration(seconds: 15),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes queued verification items persisted across restarts.
|
||||||
|
Future<void> processPendingVerificationQueue() async {
|
||||||
|
final queue = verificationQueue;
|
||||||
|
final verifier = verificationService;
|
||||||
|
if (queue == null || verifier == null || _isProcessingQueue) return;
|
||||||
|
|
||||||
|
_isProcessingQueue = true;
|
||||||
|
try {
|
||||||
|
final items = await queue.loadQueue();
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
if (now.isBefore(item.nextRetryAt)) continue;
|
||||||
|
|
||||||
|
final syntheticPurchase = StorePurchase(
|
||||||
|
purchaseId: item.purchaseId,
|
||||||
|
productId: item.productId,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: item.queuedAt.toIso8601String(),
|
||||||
|
purchaseToken: item.purchaseToken,
|
||||||
|
purchaseTokenHash: item.purchaseTokenHash,
|
||||||
|
pendingCompletePurchase: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await verifyStorePurchase(syntheticPurchase);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[EntitlementCoordinator] Queue processing error: $e');
|
||||||
|
} finally {
|
||||||
|
_isProcessingQueue = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Injected completion helper for testing and Stage 4B-3 verification callback.
|
/// Injected completion helper for testing and Stage 4B-3 verification callback.
|
||||||
/// When server verification confirms the purchase, the coordinator completes it and writes to durable storage.
|
/// When server verification confirms the purchase, the coordinator completes it and writes to durable storage.
|
||||||
Future<void> applyVerifiedEntitlement(
|
Future<void> applyVerifiedEntitlement(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,14 @@ final entitlementCoordinatorProvider =
|
||||||
ChangeNotifierProvider<EntitlementCoordinator>((ref) {
|
ChangeNotifierProvider<EntitlementCoordinator>((ref) {
|
||||||
final billing = ref.watch(billingServiceProvider);
|
final billing = ref.watch(billingServiceProvider);
|
||||||
final storage = ref.watch(entitlementStorageProvider);
|
final storage = ref.watch(entitlementStorageProvider);
|
||||||
return EntitlementCoordinator(billingService: billing, storage: storage);
|
final verifier = ref.watch(purchaseVerificationServiceProvider);
|
||||||
|
final queue = ref.watch(verificationQueueProvider);
|
||||||
|
return EntitlementCoordinator(
|
||||||
|
billingService: billing,
|
||||||
|
storage: storage,
|
||||||
|
verificationService: verifier,
|
||||||
|
verificationQueue: queue,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Authoritative entitlement state provider.
|
/// Authoritative entitlement state provider.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.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/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';
|
||||||
|
|
@ -270,6 +271,7 @@ void main() {
|
||||||
overrides: [
|
overrides: [
|
||||||
billingServiceProvider.overrideWithValue(fakeBilling),
|
billingServiceProvider.overrideWithValue(fakeBilling),
|
||||||
entitlementStorageProvider.overrideWithValue(storage),
|
entitlementStorageProvider.overrideWithValue(storage),
|
||||||
|
verificationQueueProvider.overrideWithValue(InMemoryVerificationQueue()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:motorcycle_dash/services/billing/billing_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/billing/durable_verification_queue.dart';
|
||||||
|
import 'package:motorcycle_dash/services/billing/product_catalog.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('InMemoryVerificationQueue', () {
|
||||||
|
test('enqueues, dequeues, and records backoff properly', () async {
|
||||||
|
final queue = InMemoryVerificationQueue();
|
||||||
|
expect(await queue.loadQueue(), isEmpty);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'order_1',
|
||||||
|
productId: ProductCatalog.proMonthly,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'raw_token_secret_123',
|
||||||
|
purchaseTokenHash: 'hash_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
await queue.enqueue(purchase);
|
||||||
|
var items = await queue.loadQueue();
|
||||||
|
expect(items.length, 1);
|
||||||
|
expect(items.first.purchaseId, 'order_1');
|
||||||
|
expect(items.first.productId, ProductCatalog.proMonthly);
|
||||||
|
expect(items.first.purchaseToken, 'raw_token_secret_123');
|
||||||
|
expect(items.first.attemptCount, 0);
|
||||||
|
|
||||||
|
// Duplicate enqueue is idempotent
|
||||||
|
await queue.enqueue(purchase);
|
||||||
|
items = await queue.loadQueue();
|
||||||
|
expect(items.length, 1);
|
||||||
|
|
||||||
|
// Record retry
|
||||||
|
await queue.recordRetry(
|
||||||
|
'order_1',
|
||||||
|
backoffDelay: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
items = await queue.loadQueue();
|
||||||
|
expect(items.first.attemptCount, 1);
|
||||||
|
expect(items.first.nextRetryAt.isAfter(items.first.queuedAt), isTrue);
|
||||||
|
|
||||||
|
// Dequeue
|
||||||
|
await queue.dequeue('order_1');
|
||||||
|
items = await queue.loadQueue();
|
||||||
|
expect(items, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores purchase with null or empty token', () async {
|
||||||
|
final queue = InMemoryVerificationQueue();
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'order_2',
|
||||||
|
productId: ProductCatalog.proLifetime,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queue.enqueue(purchase);
|
||||||
|
expect(await queue.loadQueue(), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:motorcycle_dash/services/billing/billing_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/billing/product_catalog.dart';
|
||||||
|
import 'package:motorcycle_dash/services/billing/purchase_verification_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PurchaseVerificationService', () {
|
||||||
|
test('missing or empty purchase token fails terminally', () async {
|
||||||
|
final service = PurchaseVerificationService();
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p1',
|
||||||
|
productId: ProductCatalog.proMonthly,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.failureType, VerificationFailureType.terminal);
|
||||||
|
expect(result.errorCode, 'MISSING_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unrecognized product ID fails terminally', () async {
|
||||||
|
final service = PurchaseVerificationService();
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p1',
|
||||||
|
productId: 'hacked_tier',
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'token_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.failureType, VerificationFailureType.terminal);
|
||||||
|
expect(result.errorCode, 'INVALID_PRODUCT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'successful monthly subscription response parses to active Pro',
|
||||||
|
() async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
expect(request.url.path, '/billing/verify-play-purchase');
|
||||||
|
final body = jsonDecode(request.body) as Map;
|
||||||
|
expect(body['productId'], ProductCatalog.proMonthly);
|
||||||
|
expect(body['purchaseToken'], 'token_valid_123');
|
||||||
|
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'productId': ProductCatalog.proMonthly,
|
||||||
|
'plan': 'proMonthly',
|
||||||
|
'status': 'proMonthlyActive',
|
||||||
|
'verificationStatus': 'verified',
|
||||||
|
'verifiedAt': '2026-09-03T18:00:00.000Z',
|
||||||
|
'expiresAt': '2026-10-03T18:00:00.000Z',
|
||||||
|
'tokenHash': 'hash_123',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = PurchaseVerificationService(
|
||||||
|
client: mockClient,
|
||||||
|
workerBaseUrl: 'https://test.worker',
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p1',
|
||||||
|
productId: ProductCatalog.proMonthly,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'token_valid_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.entitlementState, isNotNull);
|
||||||
|
final state = result.entitlementState!;
|
||||||
|
expect(state.plan, MotorSignalPlan.proMonthly);
|
||||||
|
expect(state.status, MotorSignalEntitlementStatus.proMonthlyActive);
|
||||||
|
expect(
|
||||||
|
state.verificationStatus,
|
||||||
|
EntitlementVerificationStatus.verified,
|
||||||
|
);
|
||||||
|
expect(state.isPro, isTrue);
|
||||||
|
expect(state.purchaseTokenHash, 'hash_123');
|
||||||
|
expect(state.expiresAt, DateTime.parse('2026-10-03T18:00:00.000Z'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'successful lifetime purchase parses to lifetime Pro without expiration',
|
||||||
|
() async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'productId': ProductCatalog.proLifetime,
|
||||||
|
'plan': 'proLifetime',
|
||||||
|
'status': 'proLifetime',
|
||||||
|
'verificationStatus': 'verified',
|
||||||
|
'verifiedAt': '2026-09-03T18:00:00.000Z',
|
||||||
|
'expiresAt': null,
|
||||||
|
'tokenHash': 'hash_lifetime',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = PurchaseVerificationService(
|
||||||
|
client: mockClient,
|
||||||
|
workerBaseUrl: 'https://test.worker',
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p2',
|
||||||
|
productId: ProductCatalog.proLifetime,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'token_lifetime',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.entitlementState!.plan, MotorSignalPlan.proLifetime);
|
||||||
|
expect(
|
||||||
|
result.entitlementState!.status,
|
||||||
|
MotorSignalEntitlementStatus.proLifetime,
|
||||||
|
);
|
||||||
|
expect(result.entitlementState!.expiresAt, isNull);
|
||||||
|
expect(result.entitlementState!.isPro, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'revoked lifetime purchase parses safely to non-pro revoked status',
|
||||||
|
() async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'productId': ProductCatalog.proLifetime,
|
||||||
|
'plan': 'proLifetime',
|
||||||
|
'status': 'unknown',
|
||||||
|
'verificationStatus': 'revoked',
|
||||||
|
'verifiedAt': '2026-09-03T18:00:00.000Z',
|
||||||
|
'expiresAt': null,
|
||||||
|
'tokenHash': 'hash_revoked',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = PurchaseVerificationService(
|
||||||
|
client: mockClient,
|
||||||
|
workerBaseUrl: 'https://test.worker',
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p2',
|
||||||
|
productId: ProductCatalog.proLifetime,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'token_revoked',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.entitlementState!.isPro, isFalse);
|
||||||
|
expect(
|
||||||
|
result.entitlementState!.verificationStatus,
|
||||||
|
EntitlementVerificationStatus.revoked,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('client 400 rejection classifies as terminal failure', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'code': 'INVALID_PURCHASE',
|
||||||
|
'error': 'Purchase token not found in Google Play',
|
||||||
|
}),
|
||||||
|
404,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = PurchaseVerificationService(
|
||||||
|
client: mockClient,
|
||||||
|
workerBaseUrl: 'https://test.worker',
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p1',
|
||||||
|
productId: ProductCatalog.proMonthly,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'invalid_token',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.failureType, VerificationFailureType.terminal);
|
||||||
|
expect(result.errorCode, 'INVALID_PURCHASE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rate limited 429 classifies as retryable failure', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'code': 'RATE_LIMITED', 'error': 'Too many requests'}),
|
||||||
|
429,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = PurchaseVerificationService(
|
||||||
|
client: mockClient,
|
||||||
|
workerBaseUrl: 'https://test.worker',
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p1',
|
||||||
|
productId: ProductCatalog.proMonthly,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'token_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.failureType, VerificationFailureType.retryable);
|
||||||
|
expect(result.errorCode, 'RATE_LIMITED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker 502/503 classifies as retryable failure', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'code': 'GOOGLE_TEMPORARILY_UNAVAILABLE',
|
||||||
|
'error': 'Google service unavailable',
|
||||||
|
}),
|
||||||
|
502,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = PurchaseVerificationService(
|
||||||
|
client: mockClient,
|
||||||
|
workerBaseUrl: 'https://test.worker',
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p1',
|
||||||
|
productId: ProductCatalog.proMonthly,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'token_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.failureType, VerificationFailureType.retryable);
|
||||||
|
expect(result.isRetryable, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'unsupported future schema version classifies as retryable without unlocking Pro',
|
||||||
|
() async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'schemaVersion': 99,
|
||||||
|
'productId': ProductCatalog.proMonthly,
|
||||||
|
'plan': 'proMonthly',
|
||||||
|
'status': 'proMonthlyActive',
|
||||||
|
'verificationStatus': 'verified',
|
||||||
|
'verifiedAt': '2026-09-03T18:00:00.000Z',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = PurchaseVerificationService(
|
||||||
|
client: mockClient,
|
||||||
|
workerBaseUrl: 'https://test.worker',
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = StorePurchase(
|
||||||
|
purchaseId: 'p1',
|
||||||
|
productId: ProductCatalog.proMonthly,
|
||||||
|
status: StorePurchaseStatus.purchased,
|
||||||
|
transactionDate: '2026-09-01T12:00:00Z',
|
||||||
|
purchaseToken: 'token_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.verifyPurchase(purchase: purchase);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.errorCode, 'UNSUPPORTED_SCHEMA');
|
||||||
|
expect(result.entitlementState, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue