diff --git a/apps/web/src/lib/kilo-pass/google-play-verifier.test.ts b/apps/web/src/lib/kilo-pass/google-play-verifier.test.ts new file mode 100644 index 0000000000..7fea3568a5 --- /dev/null +++ b/apps/web/src/lib/kilo-pass/google-play-verifier.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { androidpublisher_v3 } from '@googleapis/androidpublisher'; + +import { KiloPassCadence, KiloPassPaymentProvider, KiloPassTier } from './enums'; +import type * as GooglePlayVerifier from './google-play-verifier'; + +const mockGetGooglePlaySubscriptionPurchase = + jest.fn<(purchaseToken: string) => Promise>(); + +jest.mock('./google-play-sdk', () => ({ + getGooglePlaySubscriptionPurchase: mockGetGooglePlaySubscriptionPurchase, +})); + +function loadVerifier(): typeof GooglePlayVerifier { + return jest.requireActual('./google-play-verifier'); +} + +function decoded( + overrides: Partial = {} +): GooglePlayVerifier.GooglePlayDecodedPurchase { + return { + purchaseToken: 'play-token-1', + productId: 'kilopass_tier19', + latestOrderId: 'GPA.1234', + startTimeMs: 1_777_626_000_000, + expiryTimeMs: 4_102_444_800_000, + obfuscatedExternalAccountId: '550e8400-e29b-41d4-a716-446655440000', + environment: 'Sandbox', + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + rawPayload: { purchaseToken: 'play-token-1' }, + ...overrides, + }; +} + +function apiData( + overrides: Partial = {} +): androidpublisher_v3.Schema$SubscriptionPurchaseV2 { + return { + startTime: '2026-05-01T09:00:00.000Z', + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + externalAccountIdentifiers: { + obfuscatedExternalAccountId: '550e8400-e29b-41d4-a716-446655440000', + }, + lineItems: [ + { + productId: 'kilopass_tier19', + expiryTime: '2100-01-01T00:00:00.000Z', + latestSuccessfulOrderId: 'GPA.1234', + }, + ], + ...overrides, + }; +} + +describe('mapGooglePlayKiloPassPurchase', () => { + it('maps a valid Play subscription purchase to a validated Kilo Pass purchase', () => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(mapGooglePlayKiloPassPurchase(decoded())).toMatchObject({ + paymentProvider: KiloPassPaymentProvider.GooglePlay, + productId: 'kilopass_tier19', + providerTransactionId: 'GPA.1234', + providerOriginalTransactionId: 'play-token-1', + providerSubscriptionId: 'play-token-1', + appAccountToken: '550e8400-e29b-41d4-a716-446655440000', + purchaseToken: 'play-token-1', + expiresAtIso: '2100-01-01T00:00:00.000Z', + environment: 'Sandbox', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + }); + + it('rejects an empty latest order id', () => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(() => mapGooglePlayKiloPassPurchase(decoded({ latestOrderId: '' }))).toThrow( + 'Google Play purchase payload missing required identifiers' + ); + }); + + it('rejects a non-finite start time', () => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(() => mapGooglePlayKiloPassPurchase(decoded({ startTimeMs: NaN }))).toThrow( + 'Google Play subscription purchase has invalid timestamps' + ); + }); + + it('rejects a non-finite expiry time', () => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(() => mapGooglePlayKiloPassPurchase(decoded({ expiryTimeMs: NaN }))).toThrow( + 'Google Play subscription purchase has invalid timestamps' + ); + }); + + it('rejects expired subscriptions', () => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(() => + mapGooglePlayKiloPassPurchase(decoded({ expiryTimeMs: Date.now() - 1_000 })) + ).toThrow('Google Play subscription purchase has expired'); + }); + + it.each([ + 'SUBSCRIPTION_STATE_ON_HOLD', + 'SUBSCRIPTION_STATE_PAUSED', + 'SUBSCRIPTION_STATE_PENDING', + 'SUBSCRIPTION_STATE_EXPIRED', + 'SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED', + 'SUBSCRIPTION_STATE_UNSPECIFIED', + '', + ])('rejects the non-entitled state %s even with a future expiry', state => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(() => mapGooglePlayKiloPassPurchase(decoded({ subscriptionState: state }))).toThrow( + 'Google Play subscription purchase is not entitled' + ); + }); + + it.each(['SUBSCRIPTION_STATE_CANCELED', 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD'])( + 'accepts the entitled state %s while the period is unexpired', + state => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(mapGooglePlayKiloPassPurchase(decoded({ subscriptionState: state }))).toMatchObject({ + productId: 'kilopass_tier19', + tier: KiloPassTier.Tier19, + }); + } + ); + + it('rejects unknown products', () => { + const { mapGooglePlayKiloPassPurchase } = loadVerifier(); + + expect(() => mapGooglePlayKiloPassPurchase(decoded({ productId: 'unknown' }))).toThrow( + 'Google Play Kilo Pass product is not enabled' + ); + }); +}); + +describe('decodeGooglePlaySubscriptionPurchase', () => { + it('marks a test purchase as Sandbox', () => { + const { decodeGooglePlaySubscriptionPurchase } = loadVerifier(); + + const result = decodeGooglePlaySubscriptionPurchase( + apiData({ testPurchase: {} }), + 'play-token-1' + ); + + expect(result.environment).toBe('Sandbox'); + expect(result.purchaseToken).toBe('play-token-1'); + expect(result.productId).toBe('kilopass_tier19'); + expect(result.latestOrderId).toBe('GPA.1234'); + expect(result.startTimeMs).toBe(1_777_626_000_000); + expect(result.expiryTimeMs).toBe(4_102_444_800_000); + expect(result.obfuscatedExternalAccountId).toBe('550e8400-e29b-41d4-a716-446655440000'); + }); + + it('marks a non-test purchase as Production', () => { + const { decodeGooglePlaySubscriptionPurchase } = loadVerifier(); + + const result = decodeGooglePlaySubscriptionPurchase(apiData(), 'play-token-1'); + + expect(result.environment).toBe('Production'); + }); + + it('throws when lineItems is empty', () => { + const { decodeGooglePlaySubscriptionPurchase } = loadVerifier(); + + expect(() => + decodeGooglePlaySubscriptionPurchase(apiData({ lineItems: [] }), 'play-token-1') + ).toThrow('Google Play subscription purchase missing line items'); + }); + + it('throws when lineItems is missing', () => { + const { decodeGooglePlaySubscriptionPurchase } = loadVerifier(); + + expect(() => + decodeGooglePlaySubscriptionPurchase(apiData({ lineItems: undefined }), 'play-token-1') + ).toThrow('Google Play subscription purchase missing line items'); + }); + + it('falls back to the top-level latest order id when the line item has none', () => { + const { decodeGooglePlaySubscriptionPurchase } = loadVerifier(); + + const data = { + startTime: '2026-05-01T09:00:00.000Z', + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + lineItems: [ + { + productId: 'kilopass_tier19', + expiryTime: '2100-01-01T00:00:00.000Z', + }, + ], + latestOrderId: 'GPA.fallback', + } as androidpublisher_v3.Schema$SubscriptionPurchaseV2; + + const result = decodeGooglePlaySubscriptionPurchase(data, 'play-token-1'); + + expect(result.latestOrderId).toBe('GPA.fallback'); + }); +}); + +describe('verifyGooglePlayKiloPassPurchase', () => { + it('returns a validated purchase for the fixture payload', async () => { + const { verifyGooglePlayKiloPassPurchase } = loadVerifier(); + + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiData({ testPurchase: {} })); + + const result = await verifyGooglePlayKiloPassPurchase('play-token-1'); + + expect(mockGetGooglePlaySubscriptionPurchase).toHaveBeenCalledWith('play-token-1'); + expect(result).toMatchObject({ + paymentProvider: KiloPassPaymentProvider.GooglePlay, + productId: 'kilopass_tier19', + providerTransactionId: 'GPA.1234', + providerSubscriptionId: 'play-token-1', + purchaseToken: 'play-token-1', + environment: 'Sandbox', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/google-play-verifier.ts b/apps/web/src/lib/kilo-pass/google-play-verifier.ts new file mode 100644 index 0000000000..56391e78bb --- /dev/null +++ b/apps/web/src/lib/kilo-pass/google-play-verifier.ts @@ -0,0 +1,115 @@ +import type { androidpublisher_v3 } from '@googleapis/androidpublisher'; + +import type { ValidatedStoreKiloPassPurchase } from './store-subscription-completion'; +import { KiloPassPaymentProvider } from './enums'; +import { getMobileStoreKiloPassProductByGoogleProductId } from './mobile-store-products'; +import { getGooglePlaySubscriptionPurchase } from './google-play-sdk'; + +export type GooglePlayEnvironment = 'Sandbox' | 'Production'; + +export type GooglePlayDecodedPurchase = { + purchaseToken: string; + productId: string; + latestOrderId: string; + startTimeMs: number; + expiryTimeMs: number; + obfuscatedExternalAccountId?: string; + environment: GooglePlayEnvironment; + subscriptionState: string; + rawPayload: Record; +}; + +/** + * Google Play states that entitle the buyer. `CANCELED` keeps access until the + * paid period ends, and `IN_GRACE_PERIOD` keeps access while a payment retries. + * Every other state (pending, paused, on hold, expired) grants no entitlement, + * so a purchase in one of those states must never complete as `active`. + */ +const ENTITLED_GOOGLE_PLAY_SUBSCRIPTION_STATES = new Set([ + 'SUBSCRIPTION_STATE_ACTIVE', + 'SUBSCRIPTION_STATE_CANCELED', + 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD', +]); + +export function mapGooglePlayKiloPassPurchase( + decoded: GooglePlayDecodedPurchase +): ValidatedStoreKiloPassPurchase { + if (!decoded.purchaseToken || !decoded.productId || !decoded.latestOrderId) { + throw new Error('Google Play purchase payload missing required identifiers'); + } + if (!Number.isFinite(decoded.startTimeMs) || !Number.isFinite(decoded.expiryTimeMs)) { + throw new Error('Google Play subscription purchase has invalid timestamps'); + } + // Called only from the tRPC purchase-completion path; renewals and refunds enter via + // the Play notifications handler, which intentionally allows expired purchases. + if (!ENTITLED_GOOGLE_PLAY_SUBSCRIPTION_STATES.has(decoded.subscriptionState)) { + throw new Error( + `Google Play subscription purchase is not entitled: ${decoded.subscriptionState || 'unknown'}` + ); + } + if (decoded.expiryTimeMs <= Date.now()) { + throw new Error('Google Play subscription purchase has expired'); + } + + const product = getMobileStoreKiloPassProductByGoogleProductId(decoded.productId); + if (!product) { + throw new Error('Google Play Kilo Pass product is not enabled'); + } + + return { + paymentProvider: KiloPassPaymentProvider.GooglePlay, + productId: decoded.productId, + providerTransactionId: decoded.latestOrderId, + providerOriginalTransactionId: decoded.purchaseToken, + providerSubscriptionId: decoded.purchaseToken, + appAccountToken: decoded.obfuscatedExternalAccountId ?? null, + purchaseToken: decoded.purchaseToken, + environment: decoded.environment, + purchasedAtIso: new Date(decoded.startTimeMs).toISOString(), + expiresAtIso: new Date(decoded.expiryTimeMs).toISOString(), + tier: product.tier, + cadence: product.cadence, + rawPayload: decoded.rawPayload, + }; +} + +export function decodeGooglePlaySubscriptionPurchase( + apiData: androidpublisher_v3.Schema$SubscriptionPurchaseV2, + purchaseToken: string +): GooglePlayDecodedPurchase { + const lineItems = apiData.lineItems ?? []; + if (lineItems.length === 0) { + throw new Error('Google Play subscription purchase missing line items'); + } + const lineItem = lineItems[0]; + + const latestOrderId = + lineItem.latestSuccessfulOrderId ?? + (apiData as { latestOrderId?: string | null }).latestOrderId ?? + ''; + if (!latestOrderId) { + throw new Error('Google Play purchase payload missing required identifiers'); + } + + return { + purchaseToken, + productId: lineItem.productId ?? '', + latestOrderId, + startTimeMs: Date.parse(apiData.startTime ?? ''), + expiryTimeMs: Date.parse(lineItem.expiryTime ?? ''), + obfuscatedExternalAccountId: + apiData.externalAccountIdentifiers?.obfuscatedExternalAccountId ?? undefined, + environment: apiData.testPurchase != null ? 'Sandbox' : 'Production', + subscriptionState: apiData.subscriptionState ?? '', + rawPayload: apiData as unknown as Record, + }; +} + +export async function verifyGooglePlayKiloPassPurchase( + purchaseToken: string +): Promise { + const apiData = await getGooglePlaySubscriptionPurchase(purchaseToken); + return mapGooglePlayKiloPassPurchase( + decodeGooglePlaySubscriptionPurchase(apiData, purchaseToken) + ); +}