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.
|
||||
|
||||
---
|
||||
|
||||
# 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 {
|
||||
verifyPlayPurchase,
|
||||
type PlayVerificationEnv,
|
||||
type VerifyPurchaseRequest,
|
||||
} from './play-verification';
|
||||
|
||||
/**
|
||||
* MotorSignal — Cloudflare R2 Worker Gateway
|
||||
* Secure authorization gateway for MotorSignal telemetry bundle storage.
|
||||
* Firebase ID tokens are cryptographically verified before any R2 access.
|
||||
* Secure authorization gateway for MotorSignal telemetry bundle storage & purchase verification.
|
||||
*/
|
||||
|
||||
export interface Env {
|
||||
export interface Env extends PlayVerificationEnv {
|
||||
R2_BUCKET: R2Bucket;
|
||||
FIREBASE_PROJECT_ID: string;
|
||||
ENVIRONMENT: string;
|
||||
|
|
@ -41,6 +45,33 @@ export default {
|
|||
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');
|
||||
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 'billing_service.dart';
|
||||
import 'durable_verification_queue.dart';
|
||||
import 'play_billing_service.dart';
|
||||
import 'purchase_verification_service.dart';
|
||||
|
||||
/// Provider for the platform [BillingService].
|
||||
/// Defaults to [PlayBillingService] in production.
|
||||
|
|
@ -16,3 +18,14 @@ final billingAvailabilityProvider = FutureProvider<bool>((ref) async {
|
|||
final service = ref.watch(billingServiceProvider);
|
||||
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 '../billing/billing_models.dart';
|
||||
import '../billing/billing_service.dart';
|
||||
import '../billing/durable_verification_queue.dart';
|
||||
import '../billing/product_catalog.dart';
|
||||
import '../billing/purchase_verification_service.dart';
|
||||
import 'entitlement_state.dart';
|
||||
import 'entitlement_storage.dart';
|
||||
|
||||
/// Seam coordinating incoming store purchase events with cached entitlement state.
|
||||
/// Pre-server verification (Stage 4B-2):
|
||||
/// Observes store purchase events, stages unverified purchases, and preserves verified cache.
|
||||
/// In Stage 4B-3, server verification will be inserted between detection and final acknowledgement.
|
||||
/// Stage 4B-3: Authoritatively verifies store purchases via [PurchaseVerificationService]
|
||||
/// and durable retry queue [DurableVerificationQueue].
|
||||
class EntitlementCoordinator extends ChangeNotifier {
|
||||
final BillingService billingService;
|
||||
final EntitlementStorage storage;
|
||||
final PurchaseVerificationService? verificationService;
|
||||
final DurableVerificationQueue? verificationQueue;
|
||||
|
||||
StreamSubscription<List<StorePurchase>>? _purchaseSubscription;
|
||||
EntitlementState _state = const EntitlementState.free();
|
||||
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 = [];
|
||||
|
||||
EntitlementCoordinator({required this.billingService, required this.storage});
|
||||
EntitlementCoordinator({
|
||||
required this.billingService,
|
||||
required this.storage,
|
||||
this.verificationService,
|
||||
this.verificationQueue,
|
||||
});
|
||||
|
||||
EntitlementState get state => _state;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -83,15 +95,14 @@ class EntitlementCoordinator extends ChangeNotifier {
|
|||
}
|
||||
|
||||
void _handlePurchasedOrRestored(StorePurchase purchase) {
|
||||
// Stage 4B-2 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED.
|
||||
// We register the unverified purchase in the queue for Stage 4B-3 server verification.
|
||||
// Stage 4B-2/4B-3 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED.
|
||||
// We register the unverified purchase in memory.
|
||||
_unverifiedPurchases.removeWhere(
|
||||
(p) => p.purchaseId == purchase.purchaseId,
|
||||
);
|
||||
_unverifiedPurchases.add(purchase);
|
||||
|
||||
// If existing state is already valid Pro, preserve it.
|
||||
// If not verified yet, transition status to syncPending.
|
||||
// If existing state is not already valid Pro, transition status to syncPending.
|
||||
if (!_state.isPro) {
|
||||
final plan = ProductCatalog.isLifetime(purchase.productId)
|
||||
? MotorSignalPlan.proLifetime
|
||||
|
|
@ -106,6 +117,16 @@ class EntitlementCoordinator extends ChangeNotifier {
|
|||
);
|
||||
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) {
|
||||
|
|
@ -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.
|
||||
/// When server verification confirms the purchase, the coordinator completes it and writes to durable storage.
|
||||
Future<void> applyVerifiedEntitlement(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,14 @@ final entitlementCoordinatorProvider =
|
|||
ChangeNotifierProvider<EntitlementCoordinator>((ref) {
|
||||
final billing = ref.watch(billingServiceProvider);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:motorcycle_dash/services/billing/billing_models.dart';
|
||||
import 'package:motorcycle_dash/services/billing/billing_providers.dart';
|
||||
import 'package:motorcycle_dash/services/billing/durable_verification_queue.dart';
|
||||
import 'package:motorcycle_dash/services/billing/fake_billing_service.dart';
|
||||
import 'package:motorcycle_dash/services/billing/play_billing_service.dart';
|
||||
import 'package:motorcycle_dash/services/billing/product_catalog.dart';
|
||||
|
|
@ -270,6 +271,7 @@ void main() {
|
|||
overrides: [
|
||||
billingServiceProvider.overrideWithValue(fakeBilling),
|
||||
entitlementStorageProvider.overrideWithValue(storage),
|
||||
verificationQueueProvider.overrideWithValue(InMemoryVerificationQueue()),
|
||||
],
|
||||
);
|
||||
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