From 6559ead0eea3efe347c98c24dc72ed1fe067a3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 27 Aug 2026 14:43:18 +0200 Subject: [PATCH 1/5] feat(kilo-pass): handle Play real-time notifications --- .../play/notifications/route.test.ts | 134 +++ .../api/kilo-pass/play/notifications/route.ts | 59 ++ .../google-play-notifications.test.ts | 554 ++++++++++++ .../kilo-pass/google-play-notifications.ts | 818 ++++++++++++++++++ .../lib/kilo-pass/posthog-tracking.test.ts | 29 + .../web/src/lib/kilo-pass/posthog-tracking.ts | 16 +- 6 files changed, 1602 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/api/kilo-pass/play/notifications/route.test.ts create mode 100644 apps/web/src/app/api/kilo-pass/play/notifications/route.ts create mode 100644 apps/web/src/lib/kilo-pass/google-play-notifications.test.ts create mode 100644 apps/web/src/lib/kilo-pass/google-play-notifications.ts diff --git a/apps/web/src/app/api/kilo-pass/play/notifications/route.test.ts b/apps/web/src/app/api/kilo-pass/play/notifications/route.test.ts new file mode 100644 index 0000000000..b95a1ba19a --- /dev/null +++ b/apps/web/src/app/api/kilo-pass/play/notifications/route.test.ts @@ -0,0 +1,134 @@ +import { captureException } from '@sentry/nextjs'; +import { OAuth2Client } from 'google-auth-library'; + +import { getEnvVariable } from '@/lib/dotenvx'; +import { processGooglePlayKiloPassNotification } from '@/lib/kilo-pass/google-play-notifications'; +import { POST } from './route'; + +jest.mock('@sentry/nextjs', () => ({ + captureException: jest.fn(), +})); + +jest.mock('@/lib/dotenvx', () => ({ + getEnvVariable: jest.fn(), +})); + +jest.mock('@/lib/kilo-pass/google-play-notifications', () => ({ + processGooglePlayKiloPassNotification: jest.fn(), +})); + +jest.mock('google-auth-library'); + +const mockVerifyIdToken = jest.fn(); +const mockProcess = jest.mocked(processGooglePlayKiloPassNotification); +const mockGetEnvVariable = jest.mocked(getEnvVariable); + +(OAuth2Client as unknown as jest.Mock).mockImplementation(() => ({ + verifyIdToken: mockVerifyIdToken, +})); + +const AUDIENCE = 'https://app.example.com/api/kilo-pass/play/notifications'; + +function request(body: unknown, token?: string) { + const headers: Record = { 'content-type': 'application/json' }; + if (token !== undefined) { + headers.authorization = `Bearer ${token}`; + } + const req = new Request('https://app.example.com/api/kilo-pass/play/notifications', { + method: 'POST', + body: JSON.stringify(body), + headers, + }); + return req; +} + +describe('POST /api/kilo-pass/play/notifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProcess.mockResolvedValue({ processed: true }); + mockVerifyIdToken.mockResolvedValue({}); + mockGetEnvVariable.mockReturnValue(AUDIENCE); + }); + + it('returns 401 when the Authorization bearer is missing', async () => { + const response = await POST(request({ message: { data: 'payload' } })); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + it('returns 401 when the OIDC token is invalid', async () => { + mockVerifyIdToken.mockRejectedValueOnce(new Error('invalid token')); + + const response = await POST(request({ message: { data: 'payload' } }, 'bad-token')); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockVerifyIdToken).toHaveBeenCalledWith({ + idToken: 'bad-token', + audience: AUDIENCE, + }); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + it('returns 401 when GOOGLE_PLAY_RTDN_PUSH_AUDIENCE is not configured', async () => { + mockGetEnvVariable.mockReturnValue(''); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + it('returns 400 when the Pub/Sub message data is missing', async () => { + const response = await POST(request({ message: {} }, 'valid-token')); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Missing Pub/Sub message data' }); + }); + + it('processes Play notification payloads', async () => { + const response = await POST( + request({ message: { data: 'cGF5bG9hZA==', messageId: 'msg-1' } }, 'valid-token') + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ processed: true }); + expect(mockProcess).toHaveBeenCalledWith({ + pubsubMessage: { data: 'cGF5bG9hZA==', messageId: 'msg-1' }, + }); + }); + + it('treats already-processed duplicate notifications as idempotent success', async () => { + mockProcess.mockResolvedValueOnce({ processed: true, status: 'already_processed' }); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ processed: true, status: 'already_processed' }); + }); + + it('asks Pub/Sub to retry fresh in-flight duplicate notifications', async () => { + mockProcess.mockResolvedValueOnce({ processed: false, status: 'in_flight' }); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ processed: false, status: 'in_flight' }); + }); + + it('captures processing failures without exposing details', async () => { + const error = new Error('bad payload'); + mockProcess.mockRejectedValueOnce(error); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Failed to process notification' }); + expect(captureException).toHaveBeenCalledWith(error, { + tags: { source: 'google_play_kilo_pass_notification' }, + }); + }); +}); diff --git a/apps/web/src/app/api/kilo-pass/play/notifications/route.ts b/apps/web/src/app/api/kilo-pass/play/notifications/route.ts new file mode 100644 index 0000000000..fd4bc217d2 --- /dev/null +++ b/apps/web/src/app/api/kilo-pass/play/notifications/route.ts @@ -0,0 +1,59 @@ +import { captureException } from '@sentry/nextjs'; +import * as z from 'zod'; +import { OAuth2Client } from 'google-auth-library'; + +import { getEnvVariable } from '@/lib/dotenvx'; +import { processGooglePlayKiloPassNotification } from '@/lib/kilo-pass/google-play-notifications'; + +const GooglePlayNotificationBodySchema = z.object({ + message: z.object({ + data: z.string().min(1), + messageId: z.string().optional(), + }), +}); + +async function verifyGooglePlayRtdnToken(idToken: string): Promise { + const audience = getEnvVariable('GOOGLE_PLAY_RTDN_PUSH_AUDIENCE'); + if (!audience) { + throw new Error('GOOGLE_PLAY_RTDN_PUSH_AUDIENCE is not set'); + } + const client = new OAuth2Client(); + await client.verifyIdToken({ idToken, audience }); +} + +export async function POST(request: Request) { + try { + const authorization = request.headers.get('authorization'); + const bearerToken = authorization?.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : null; + if (!bearerToken) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await verifyGooglePlayRtdnToken(bearerToken); + } catch { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = GooglePlayNotificationBodySchema.safeParse(await request.json()); + if (!body.success) { + return Response.json({ error: 'Missing Pub/Sub message data' }, { status: 400 }); + } + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: { + data: body.data.message.data, + messageId: body.data.message.messageId, + }, + }); + if ('status' in result && result.status === 'in_flight') { + return Response.json(result, { status: 503 }); + } + return Response.json(result); + } catch (error) { + captureException(error, { tags: { source: 'google_play_kilo_pass_notification' } }); + return Response.json({ error: 'Failed to process notification' }, { status: 500 }); + } +} diff --git a/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts b/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts new file mode 100644 index 0000000000..5335a47ae2 --- /dev/null +++ b/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts @@ -0,0 +1,554 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { androidpublisher_v3 } from '@googleapis/androidpublisher'; +import { and, eq, sql } from 'drizzle-orm'; + +import { + credit_transactions, + kilo_pass_issuance_items, + kilo_pass_issuances, + kilocode_users, + kilo_pass_store_events, + kilo_pass_store_purchases, + kilo_pass_subscriptions, +} from '@kilocode/db/schema'; +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { KiloPassIssuanceItemKind, KiloPassPaymentProvider } from './enums'; +import type * as GooglePlayNotifications from './google-play-notifications'; +import { toMicrodollars } from '@/lib/utils'; + +const mockGetGooglePlaySubscriptionPurchase = jest.fn(); + +jest.mock('./google-play-sdk', () => ({ + getGooglePlaySubscriptionPurchase: mockGetGooglePlaySubscriptionPurchase, + GOOGLE_PLAY_PACKAGE_NAME: 'com.kilocode.kiloapp', +})); + +// SWC + static ESM imports do not see jest.mock replacements on the same module id. +// Dynamic-import the SUT after the mock (same pattern as apple-store-notifications.test.ts). +jest.mock('@/lib/kilo-pass/posthog-tracking', () => ({ + runAfterResponse: async (work: () => Promise) => { + await work(); + }, + trackKiloPassPurchaseCompleted: jest.fn(), +})); + +type PosthogTrackingMock = { + trackKiloPassPurchaseCompleted: jest.Mock; + runAfterResponse: (work: () => Promise) => Promise; +}; + +function getPosthogTrackingMock(): PosthogTrackingMock { + return jest.requireMock('@/lib/kilo-pass/posthog-tracking') as PosthogTrackingMock; +} + +let processGooglePlayKiloPassNotification: typeof GooglePlayNotifications.processGooglePlayKiloPassNotification; + +const GOOGLE_PLAY_NOTIFICATION_TEST_NOW_MS = Date.parse('2026-05-15T00:00:00.000Z'); + +function pubsubMessage( + params: { + packageName?: string; + notificationType?: number; + purchaseToken?: string; + eventTimeMillis?: string | number; + messageId?: string; + omitSubscriptionNotification?: boolean; + } = {} +): GooglePlayNotifications.GooglePlayPubSubMessage { + const notification: Record = { + version: '1.0', + packageName: params.packageName ?? 'com.kilocode.kiloapp', + eventTimeMillis: params.eventTimeMillis ?? String(GOOGLE_PLAY_NOTIFICATION_TEST_NOW_MS), + }; + if (!params.omitSubscriptionNotification) { + notification.subscriptionNotification = { + version: '1.0', + notificationType: params.notificationType ?? 4, + purchaseToken: params.purchaseToken ?? 'play-token-1', + subscriptionId: 'kilopass_tier19', + }; + } + const data = Buffer.from(JSON.stringify(notification)).toString('base64'); + return { + data, + messageId: params.messageId, + }; +} + +function apiData( + overrides: Partial = {} +): androidpublisher_v3.Schema$SubscriptionPurchaseV2 { + return { + startTime: '2026-05-01T09:00:00.000Z', + subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE', + lineItems: [ + { + productId: 'kilopass_tier19', + expiryTime: '2100-01-01T00:00:00.000Z', + latestSuccessfulOrderId: `GPA.${crypto.randomUUID()}`, + }, + ], + ...overrides, + }; +} + +function apiDataForUser( + obfsAccountId: string, + orderId = `GPA.${crypto.randomUUID()}`, + overrides: Partial = {} +): androidpublisher_v3.Schema$SubscriptionPurchaseV2 { + return apiData({ + externalAccountIdentifiers: { obfuscatedExternalAccountId: obfsAccountId }, + lineItems: [ + { + productId: 'kilopass_tier19', + expiryTime: '2100-01-01T00:00:00.000Z', + latestSuccessfulOrderId: orderId, + }, + ], + ...overrides, + }); +} + +async function insertGooglePlayUser(): Promise<{ + user: Awaited>; + obfsAccountId: string; +}> { + const obfsAccountId = crypto.randomUUID(); + const user = await insertTestUser({ app_store_account_token: obfsAccountId }); + return { user, obfsAccountId }; +} + +describe('processGooglePlayKiloPassNotification', () => { + let dateNowSpy: jest.SpiedFunction; + + beforeAll(async () => { + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(GOOGLE_PLAY_NOTIFICATION_TEST_NOW_MS); + ({ processGooglePlayKiloPassNotification } = await import('./google-play-notifications')); + }); + + afterAll(() => { + dateNowSpy.mockRestore(); + }); + + beforeEach(() => { + getPosthogTrackingMock().trackKiloPassPurchaseCompleted.mockClear(); + mockGetGooglePlaySubscriptionPurchase.mockReset(); + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiData()); + }); + + it('completes a purchased notification and tracks google_play', async () => { + const trackingMock = getPosthogTrackingMock(); + const { user, obfsAccountId } = await insertGooglePlayUser(); + const orderId = `GPA.${crypto.randomUUID()}`; + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(obfsAccountId, orderId)); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-purchased', + messageId: 'msg-purchased', + }), + }); + + expect(result).toEqual({ processed: true }); + + const subscription = await db.query.kilo_pass_subscriptions.findFirst({ + where: and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, 'purchase-token-purchased') + ), + }); + expect(subscription).toBeDefined(); + expect(subscription?.status).toBe('active'); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'google_play', + userId: user.id, + purchaseKind: 'initial', + providerTransactionId: orderId, + productId: 'kilopass_tier19', + environment: 'Production', + }) + ); + }); + + it('completes a renewed notification as a renewal and tracks google_play', async () => { + const trackingMock = getPosthogTrackingMock(); + const { obfsAccountId } = await insertGooglePlayUser(); + const initialOrderId = `GPA.${crypto.randomUUID()}`; + const renewalOrderId = `GPA.${crypto.randomUUID()}`; + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue( + apiDataForUser(obfsAccountId, initialOrderId) + ); + + await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-renewed', + messageId: 'renewal-initial', + }), + }); + trackingMock.trackKiloPassPurchaseCompleted.mockClear(); + + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue( + apiDataForUser(obfsAccountId, renewalOrderId) + ); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 2, + purchaseToken: 'purchase-token-renewed', + messageId: 'renewal-2', + }), + }); + + expect(result).toEqual({ processed: true }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'google_play', + purchaseKind: 'renewal', + providerTransactionId: renewalOrderId, + }) + ); + }); + + it('sets cancel_at_period_end for a canceled notification', async () => { + const { obfsAccountId } = await insertGooglePlayUser(); + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(obfsAccountId)); + + await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-canceled', + messageId: 'cancel-initial', + }), + }); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 3, + purchaseToken: 'purchase-token-canceled', + messageId: 'cancel-1', + }), + }); + + expect(result).toEqual({ processed: true }); + + const subscription = await db.query.kilo_pass_subscriptions.findFirst({ + where: and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, 'purchase-token-canceled') + ), + }); + expect(subscription?.cancel_at_period_end).toBe(true); + expect(subscription?.status).toBe('active'); + }); + + it('ends the subscription for an expired notification', async () => { + const { obfsAccountId } = await insertGooglePlayUser(); + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(obfsAccountId)); + + await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-expired', + messageId: 'expire-initial', + }), + }); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 13, + purchaseToken: 'purchase-token-expired', + messageId: 'expire-1', + }), + }); + + expect(result).toEqual({ processed: true }); + + const subscription = await db.query.kilo_pass_subscriptions.findFirst({ + where: and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, 'purchase-token-expired') + ), + }); + expect(subscription?.status).toBe('canceled'); + expect(subscription?.ended_at).not.toBeNull(); + expect(subscription?.cancel_at_period_end).toBe(false); + }); + + it('reverses the matched purchase base plus issued bonus and promo credits and ends the subscription', async () => { + const { user, obfsAccountId } = await insertGooglePlayUser(); + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(obfsAccountId)); + + await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-revoked', + messageId: 'revoke-initial', + }), + }); + + const subscription = await db.query.kilo_pass_subscriptions.findFirst({ + where: and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, 'purchase-token-revoked') + ), + }); + expect(subscription).toBeDefined(); + + const issuance = await db.query.kilo_pass_issuances.findFirst({ + where: eq(kilo_pass_issuances.kilo_pass_subscription_id, subscription?.id ?? ''), + }); + expect(issuance).toBeDefined(); + + const [bonusTransaction, promoTransaction] = await Promise.all([ + db + .insert(credit_transactions) + .values({ + kilo_user_id: user.id, + amount_microdollars: toMicrodollars(9.5), + is_free: true, + description: 'test Kilo Pass bonus credits', + credit_category: `test-kilo-pass-bonus-${crypto.randomUUID()}`, + }) + .returning({ id: credit_transactions.id }), + db + .insert(credit_transactions) + .values({ + kilo_user_id: user.id, + amount_microdollars: toMicrodollars(4.75), + is_free: true, + description: 'test Kilo Pass promo credits', + credit_category: `test-kilo-pass-promo-${crypto.randomUUID()}`, + }) + .returning({ id: credit_transactions.id }), + ]); + + await db + .update(kilocode_users) + .set({ + total_microdollars_acquired: sql`${kilocode_users.total_microdollars_acquired} + ${toMicrodollars( + 14.25 + )}`, + }) + .where(eq(kilocode_users.id, user.id)); + + await db.insert(kilo_pass_issuance_items).values([ + { + kilo_pass_issuance_id: issuance?.id ?? '', + kind: KiloPassIssuanceItemKind.Bonus, + credit_transaction_id: bonusTransaction[0]?.id ?? '', + amount_usd: 9.5, + bonus_percent_applied: 0.5, + }, + { + kilo_pass_issuance_id: issuance?.id ?? '', + kind: KiloPassIssuanceItemKind.PromoFirstMonth50Pct, + credit_transaction_id: promoTransaction[0]?.id ?? '', + amount_usd: 4.75, + bonus_percent_applied: 0.25, + }, + ]); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 12, + purchaseToken: 'purchase-token-revoked', + messageId: 'revoke-1', + }), + }); + + expect(result).toEqual({ processed: true }); + + const creditTransactions = await db + .select({ + amountMicrodollars: credit_transactions.amount_microdollars, + description: credit_transactions.description, + }) + .from(credit_transactions) + .where(eq(credit_transactions.kilo_user_id, user.id)); + expect(creditTransactions.filter(row => row.amountMicrodollars < 0)).toHaveLength(3); + expect(creditTransactions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amountMicrodollars: -toMicrodollars(19), + description: 'Google Play Kilo Pass refund clawback', + }), + expect.objectContaining({ + amountMicrodollars: -toMicrodollars(9.5), + description: 'Google Play Kilo Pass bonus refund clawback', + }), + expect.objectContaining({ + amountMicrodollars: -toMicrodollars(4.75), + description: 'Google Play Kilo Pass promo refund clawback', + }), + ]) + ); + + const endedSubscription = await db.query.kilo_pass_subscriptions.findFirst({ + where: eq(kilo_pass_subscriptions.provider_subscription_id, 'purchase-token-revoked'), + }); + expect(endedSubscription?.status).toBe('canceled'); + expect(endedSubscription?.ended_at).not.toBeNull(); + expect(endedSubscription?.cancel_at_period_end).toBe(false); + }); + + it('returns already_processed for a duplicate messageId', async () => { + const { obfsAccountId } = await insertGooglePlayUser(); + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(obfsAccountId)); + + const params = { + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-duplicate', + messageId: 'msg-duplicate', + }), + }; + + await processGooglePlayKiloPassNotification(params); + const replay = await processGooglePlayKiloPassNotification(params); + + expect(replay).toEqual({ processed: true, status: 'already_processed' }); + }); + + it('returns in_flight for a fresh in-flight duplicate delivery', async () => { + await db.insert(kilo_pass_store_events).values({ + payment_provider: KiloPassPaymentProvider.GooglePlay, + event_id: 'msg-inflight', + provider_subscription_id: 'purchase-token-inflight', + provider_transaction_id: 'GPA.1234', + app_account_token: crypto.randomUUID(), + product_id: 'kilopass_tier19', + environment: 'Production', + payload_json: { + notificationType: 4, + eventTimeMillis: GOOGLE_PLAY_NOTIFICATION_TEST_NOW_MS, + }, + processing_started_at: new Date().toISOString(), + processed_at: null, + }); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-inflight', + messageId: 'msg-inflight', + }), + }); + + expect(result).toEqual({ processed: false, status: 'in_flight' }); + }); + + it('throws on a package mismatch', async () => { + await expect( + processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + packageName: 'com.other.app', + notificationType: 4, + purchaseToken: 'purchase-token-package', + }), + }) + ).rejects.toThrow('Google Play notification package mismatch'); + }); + + it('marks a purchased notification processed when no user exists', async () => { + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(crypto.randomUUID())); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-no-user', + messageId: 'msg-no-user', + }), + }); + + expect(result).toEqual({ processed: true }); + + const event = await db.query.kilo_pass_store_events.findFirst({ + where: eq(kilo_pass_store_events.event_id, 'msg-no-user'), + }); + expect(event?.processed_at).not.toBeNull(); + + const subscriptions = await db + .select() + .from(kilo_pass_subscriptions) + .where( + and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, 'purchase-token-no-user') + ) + ); + expect(subscriptions).toHaveLength(0); + }); + + it('throws for a renewal notification without a user', async () => { + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(crypto.randomUUID())); + + await expect( + processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 2, + purchaseToken: 'purchase-token-renewal-no-user', + messageId: 'msg-renewal-no-user', + }), + }) + ).rejects.toThrow( + 'Google Play renewal notification cannot create a subscription without a user' + ); + }); + + it('skips completion when a processed revoked event for the same purchase token exists', async () => { + const { user, obfsAccountId } = await insertGooglePlayUser(); + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue(apiDataForUser(obfsAccountId)); + + await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 4, + purchaseToken: 'purchase-token-terminal', + messageId: 'terminal-initial', + }), + }); + + await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 12, + purchaseToken: 'purchase-token-terminal', + messageId: 'terminal-revoked', + }), + }); + + const delayedOrderId = `GPA.${crypto.randomUUID()}`; + mockGetGooglePlaySubscriptionPurchase.mockResolvedValue( + apiDataForUser(obfsAccountId, delayedOrderId, { + startTime: '2026-05-10T09:00:00.000Z', + }) + ); + + const result = await processGooglePlayKiloPassNotification({ + pubsubMessage: pubsubMessage({ + notificationType: 2, + purchaseToken: 'purchase-token-terminal', + messageId: 'terminal-delayed-renewal', + }), + }); + + expect(result).toEqual({ processed: true }); + + const delayedStorePurchases = await db + .select() + .from(kilo_pass_store_purchases) + .where(eq(kilo_pass_store_purchases.provider_transaction_id, delayedOrderId)); + expect(delayedStorePurchases).toHaveLength(0); + + const userCreditTransactions = await db + .select() + .from(credit_transactions) + .where(eq(credit_transactions.kilo_user_id, user.id)); + // Base + reversed base only: the delayed renewal issued nothing new. + expect(userCreditTransactions.filter(row => row.amount_microdollars > 0)).toHaveLength(1); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/google-play-notifications.ts b/apps/web/src/lib/kilo-pass/google-play-notifications.ts new file mode 100644 index 0000000000..58379165a4 --- /dev/null +++ b/apps/web/src/lib/kilo-pass/google-play-notifications.ts @@ -0,0 +1,818 @@ +import { and, desc, eq, inArray, or, sql } from 'drizzle-orm'; +import { captureException } from '@sentry/nextjs'; + +import { + credit_transactions, + kilo_pass_issuance_items, + kilo_pass_issuances, + kilo_pass_store_events, + kilo_pass_store_purchases, + kilo_pass_subscriptions, + kilocode_users, + type User, +} from '@kilocode/db/schema'; +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { KiloPassAuditLogAction, KiloPassAuditLogResult, KiloPassPaymentProvider } from './enums'; +import { KiloPassIssuanceItemKind } from './enums'; +import { appendKiloPassAuditLog } from './issuance'; +import { + decodeGooglePlaySubscriptionPurchase, + mapGooglePlayKiloPassPurchase, +} from './google-play-verifier'; +import { GOOGLE_PLAY_PACKAGE_NAME, getGooglePlaySubscriptionPurchase } from './google-play-sdk'; +import { + completeStoreKiloPassPurchase, + isStorePurchaseMismatchError, + type CompleteStoreKiloPassPurchaseResult, +} from './store-subscription-completion'; +import { runAfterResponse, trackKiloPassPurchaseCompleted } from '@/lib/kilo-pass/posthog-tracking'; +import { redactStoreAccountLinkedJson } from './store-payload-redaction'; +import { dayjs } from './dayjs'; + +type DbOrTx = DrizzleTransaction | typeof db; + +export type GooglePlayPubSubMessage = { + data: string; + messageId?: string; +}; + +type GooglePlayDeveloperNotification = { + packageName?: string; + eventTimeMillis?: string | number; + subscriptionNotification?: { + notificationType?: number; + purchaseToken?: string; + subscriptionId?: string; + }; +}; + +export type GooglePlayKiloPassNotificationProcessingResult = + | { processed: true } + | { processed: true; status: 'already_processed' } + | { processed: false; status: 'in_flight' }; + +// Google Play Real-time Developer Notification subscription types: +// https://developer.android.com/google/play/billing/subscriptions#notification-types +const GOOGLE_PLAY_NOTIFICATION_TYPE = { + SUBSCRIPTION_RECOVERED: 1, + SUBSCRIPTION_RENEWED: 2, + SUBSCRIPTION_CANCELED: 3, + SUBSCRIPTION_PURCHASED: 4, + SUBSCRIPTION_RESTARTED: 7, + SUBSCRIPTION_REVOKED: 12, + SUBSCRIPTION_EXPIRED: 13, +} as const; + +const PURCHASE_TYPES = new Set([ + GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_RECOVERED, + GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_RENEWED, + GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_PURCHASED, + GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_RESTARTED, +]); + +const STORE_EVENT_CLAIM_STALE_AFTER_MS = 5 * 60 * 1000; + +type StoreEventClaimStatus = 'claimed' | 'already_processed' | 'in_flight'; + +function decodeGooglePlayDeveloperNotification(data: string): GooglePlayDeveloperNotification { + const json = Buffer.from(data, 'base64').toString('utf8'); + const parsed: unknown = JSON.parse(json); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Google Play notification payload is not a JSON object'); + } + return parsed as GooglePlayDeveloperNotification; +} + +function computeGooglePlayEventId(params: { + messageId?: string; + purchaseToken: string; + notificationType: number; + eventTimeMillis: string | number | null; +}): string { + if (params.messageId) { + return params.messageId; + } + if (params.eventTimeMillis != null) { + return `${params.purchaseToken}:${params.notificationType}:${params.eventTimeMillis}`; + } + throw new Error('Google Play notification missing event id'); +} + +function getGooglePlayStoreEventPayload(params: { + notificationType: number; + packageName: string; + eventTimeMillis: string | number | null; + purchaseToken: string; + latestOrderId: string; + appAccountToken: string | null; + productId: string; + environment: string; +}): Record { + return redactStoreAccountLinkedJson({ + notificationType: params.notificationType, + packageName: params.packageName, + eventTimeMillis: params.eventTimeMillis, + purchaseToken: params.purchaseToken, + latestOrderId: params.latestOrderId, + appAccountToken: params.appAccountToken, + productId: params.productId, + environment: params.environment, + }); +} + +async function claimGooglePlayStoreEventForProcessing(params: { + eventId: string; + notificationType: number; + packageName: string; + eventTimeMillis: string | number | null; + purchaseToken: string; + latestOrderId: string; + appAccountToken: string | null; + productId: string; + environment: string; +}): Promise { + const processingStartedAtIso = new Date().toISOString(); + const staleBeforeIso = new Date(Date.now() - STORE_EVENT_CLAIM_STALE_AFTER_MS).toISOString(); + + const payloadJson = getGooglePlayStoreEventPayload(params); + + const claimedRows = await db + .insert(kilo_pass_store_events) + .values({ + payment_provider: KiloPassPaymentProvider.GooglePlay, + event_id: params.eventId, + provider_subscription_id: params.purchaseToken, + provider_transaction_id: params.latestOrderId, + app_account_token: params.appAccountToken, + product_id: params.productId, + environment: params.environment, + payload_json: payloadJson, + processing_started_at: processingStartedAtIso, + }) + .onConflictDoUpdate({ + target: [kilo_pass_store_events.payment_provider, kilo_pass_store_events.event_id], + set: { + provider_subscription_id: params.purchaseToken, + provider_transaction_id: params.latestOrderId, + app_account_token: params.appAccountToken, + product_id: params.productId, + environment: params.environment, + payload_json: payloadJson, + processing_started_at: processingStartedAtIso, + }, + setWhere: sql`${kilo_pass_store_events.processed_at} IS NULL AND (${kilo_pass_store_events.processing_started_at} IS NULL OR ${kilo_pass_store_events.processing_started_at} < ${staleBeforeIso})`, + }) + .returning({ id: kilo_pass_store_events.id }); + + if (claimedRows.length > 0) { + return 'claimed'; + } + + const existingEvent = await db.query.kilo_pass_store_events.findFirst({ + columns: { processed_at: true }, + where: and( + eq(kilo_pass_store_events.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_events.event_id, params.eventId) + ), + }); + + return existingEvent?.processed_at ? 'already_processed' : 'in_flight'; +} + +async function markGooglePlayStoreEventProcessed(eventId: string): Promise { + await db + .update(kilo_pass_store_events) + .set({ processed_at: new Date().toISOString() }) + .where( + and( + eq(kilo_pass_store_events.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_events.event_id, eventId) + ) + ); +} + +async function markGooglePlaySubscriptionCancelingAtPeriodEnd( + purchaseToken: string +): Promise { + await db + .update(kilo_pass_subscriptions) + .set({ + cancel_at_period_end: true, + }) + .where( + and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, purchaseToken) + ) + ); +} + +async function markGooglePlaySubscriptionEnded( + dbOrTx: DbOrTx, + purchaseToken: string +): Promise { + await dbOrTx + .update(kilo_pass_subscriptions) + .set({ + status: 'canceled', + cancel_at_period_end: false, + ended_at: new Date().toISOString(), + }) + .where( + and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, purchaseToken) + ) + ); +} + +async function getUserForGooglePlayRenewal(params: { + providerSubscriptionId: string; + appAccountToken: string | null; +}): Promise { + const row = await db + .select({ user: kilocode_users }) + .from(kilo_pass_subscriptions) + .innerJoin(kilocode_users, eq(kilo_pass_subscriptions.kilo_user_id, kilocode_users.id)) + .where( + and( + eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_subscriptions.provider_subscription_id, params.providerSubscriptionId) + ) + ) + .limit(1); + + if (row[0]?.user) { + if (row[0].user.app_store_account_token !== params.appAccountToken) { + throw new Error('Google Play renewal account token does not match subscription owner'); + } + return row[0].user; + } + + if (!params.appAccountToken) return null; + + const tokenRows = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.app_store_account_token, params.appAccountToken)) + .limit(1); + + return tokenRows[0] ?? null; +} + +type CreditReversalResult = { + storePurchaseFound: boolean; + creditTransactionIds: string[]; + totalReversalMicrodollars: number; + reversedItemKinds: KiloPassIssuanceItemKind[]; +}; + +async function insertCreditReversal( + tx: DrizzleTransaction, + params: { + kiloUserId: string; + amountMicrodollars: number; + isFree: boolean; + description: string; + creditCategory: string; + originalBaselineMicrodollarsUsed: number; + } +): Promise<{ wasInserted: boolean; creditTransactionId: string | null }> { + const creditTransactionId = crypto.randomUUID(); + const insertResult = await tx + .insert(credit_transactions) + .values({ + id: creditTransactionId, + kilo_user_id: params.kiloUserId, + amount_microdollars: -params.amountMicrodollars, + is_free: params.isFree, + description: params.description, + credit_category: params.creditCategory, + check_category_uniqueness: true, + original_baseline_microdollars_used: params.originalBaselineMicrodollarsUsed, + }) + .onConflictDoNothing(); + + if ((insertResult.rowCount ?? 0) === 0) { + const existingRows = await tx + .select({ id: credit_transactions.id }) + .from(credit_transactions) + .where( + and( + eq(credit_transactions.kilo_user_id, params.kiloUserId), + eq(credit_transactions.credit_category, params.creditCategory) + ) + ) + .limit(1); + return { wasInserted: false, creditTransactionId: existingRows[0]?.id ?? null }; + } + + await tx + .update(kilocode_users) + .set({ + total_microdollars_acquired: sql`${kilocode_users.total_microdollars_acquired} - ${params.amountMicrodollars}`, + }) + .where(eq(kilocode_users.id, params.kiloUserId)); + + return { wasInserted: true, creditTransactionId }; +} + +function getGooglePlayRefundReversalDescription(kind: KiloPassIssuanceItemKind): string { + if (kind === KiloPassIssuanceItemKind.Base) { + return 'Google Play Kilo Pass refund clawback'; + } + if (kind === KiloPassIssuanceItemKind.Bonus) { + return 'Google Play Kilo Pass bonus refund clawback'; + } + return 'Google Play Kilo Pass promo refund clawback'; +} + +function getGooglePlayProviderPaymentId(providerTransactionId: string): string { + return `kilo-pass:${KiloPassPaymentProvider.GooglePlay}:${providerTransactionId}`; +} + +function getGooglePlayUpgradeBaseCreditCategory(providerTransactionId: string): string { + return `kilo-pass-upgrade-base:${KiloPassPaymentProvider.GooglePlay}:${providerTransactionId}`; +} + +async function reverseGooglePlayRefundCredits( + tx: DrizzleTransaction, + purchaseToken: string, + latestOrderId: string +): Promise { + let storePurchase = await tx.query.kilo_pass_store_purchases.findFirst({ + where: and( + eq(kilo_pass_store_purchases.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_purchases.provider_subscription_id, purchaseToken), + eq(kilo_pass_store_purchases.provider_transaction_id, latestOrderId) + ), + }); + + if (!storePurchase) { + storePurchase = await tx.query.kilo_pass_store_purchases.findFirst({ + where: and( + eq(kilo_pass_store_purchases.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_purchases.provider_subscription_id, purchaseToken) + ), + orderBy: desc(kilo_pass_store_purchases.purchased_at), + }); + } + + if (!storePurchase) { + return { + storePurchaseFound: false, + creditTransactionIds: [], + totalReversalMicrodollars: 0, + reversedItemKinds: [], + }; + } + + const providerTransactionId = storePurchase.provider_transaction_id; + + const userRows = await tx + .select({ microdollarsUsed: kilocode_users.microdollars_used }) + .from(kilocode_users) + .where(eq(kilocode_users.id, storePurchase.kilo_user_id)) + .for('update') + .limit(1); + const user = userRows[0]; + if (!user) { + throw new Error('Google Play refund cannot find the subscribed user'); + } + + const ownedBaseCreditRows = await tx + .select({ + creditTransactionId: credit_transactions.id, + amountMicrodollars: credit_transactions.amount_microdollars, + isFree: credit_transactions.is_free, + }) + .from(credit_transactions) + .where( + and( + eq(credit_transactions.kilo_user_id, storePurchase.kilo_user_id), + or( + eq( + credit_transactions.stripe_payment_id, + getGooglePlayProviderPaymentId(providerTransactionId) + ), + eq( + credit_transactions.credit_category, + getGooglePlayUpgradeBaseCreditCategory(providerTransactionId) + ) + ) + ) + ) + .limit(1); + + const ownedBaseCredit = ownedBaseCreditRows[0] ?? null; + + const issueMonth = dayjs(storePurchase.purchased_at).utc().format('YYYY-MM-01'); + const issuance = await tx.query.kilo_pass_issuances.findFirst({ + where: and( + eq(kilo_pass_issuances.kilo_pass_subscription_id, storePurchase.kilo_pass_subscription_id), + eq(kilo_pass_issuances.issue_month, issueMonth) + ), + }); + + const issuedItems: { + itemId: string; + kind: KiloPassIssuanceItemKind; + amountMicrodollars: number; + isFree: boolean; + }[] = []; + + if (ownedBaseCredit) { + issuedItems.push({ + itemId: ownedBaseCredit.creditTransactionId, + kind: KiloPassIssuanceItemKind.Base, + amountMicrodollars: ownedBaseCredit.amountMicrodollars, + isFree: ownedBaseCredit.isFree, + }); + } + + if (issuance) { + const currentBaseItemRows = await tx + .select({ itemId: kilo_pass_issuance_items.id }) + .from(kilo_pass_issuance_items) + .innerJoin( + credit_transactions, + eq(kilo_pass_issuance_items.credit_transaction_id, credit_transactions.id) + ) + .where( + and( + eq(kilo_pass_issuance_items.kilo_pass_issuance_id, issuance.id), + eq(kilo_pass_issuance_items.kind, KiloPassIssuanceItemKind.Base), + or( + eq( + credit_transactions.stripe_payment_id, + getGooglePlayProviderPaymentId(providerTransactionId) + ), + eq( + credit_transactions.credit_category, + getGooglePlayUpgradeBaseCreditCategory(providerTransactionId) + ) + ) + ) + ) + .limit(1); + + if (currentBaseItemRows[0]) { + const bonusItems = await tx + .select({ + itemId: kilo_pass_issuance_items.id, + kind: kilo_pass_issuance_items.kind, + amountMicrodollars: credit_transactions.amount_microdollars, + isFree: credit_transactions.is_free, + }) + .from(kilo_pass_issuance_items) + .innerJoin( + credit_transactions, + eq(kilo_pass_issuance_items.credit_transaction_id, credit_transactions.id) + ) + .where( + and( + eq(kilo_pass_issuance_items.kilo_pass_issuance_id, issuance.id), + inArray(kilo_pass_issuance_items.kind, [ + KiloPassIssuanceItemKind.Bonus, + KiloPassIssuanceItemKind.PromoFirstMonth50Pct, + ]) + ) + ); + issuedItems.push(...bonusItems); + } + } + + const creditTransactionIds: string[] = []; + const reversedItemKinds: KiloPassIssuanceItemKind[] = []; + let totalReversalMicrodollars = 0; + for (const item of issuedItems) { + // Reverse what Kilo granted, never the Play store price. Google returns the + // full charge to the customer and reverses its own commission, so clawing + // back the store price would leave a customer who spent nothing at minus the + // store margin. + const reversalAmountMicrodollars = item.amountMicrodollars; + + if (reversalAmountMicrodollars <= 0) { + continue; + } + + const reversal = await insertCreditReversal(tx, { + kiloUserId: storePurchase.kilo_user_id, + amountMicrodollars: reversalAmountMicrodollars, + isFree: item.isFree, + description: getGooglePlayRefundReversalDescription(item.kind), + creditCategory: `kilo-pass-store-refund:${KiloPassPaymentProvider.GooglePlay}:${providerTransactionId}:${item.kind}:${item.itemId}`, + originalBaselineMicrodollarsUsed: user.microdollarsUsed, + }); + if (reversal.creditTransactionId) { + creditTransactionIds.push(reversal.creditTransactionId); + } + if (reversal.wasInserted) { + totalReversalMicrodollars += reversalAmountMicrodollars; + reversedItemKinds.push(item.kind); + } + } + + return { + storePurchaseFound: true, + creditTransactionIds, + totalReversalMicrodollars, + reversedItemKinds, + }; +} + +type TerminalStoreEvent = { + eventId: string; + notificationType: string | null; + terminalTimestampMs: number | null; +}; + +async function findProcessedTerminalStoreEventForGooglePlayPurchase(params: { + purchaseToken: string; + providerTransactionId: string; + purchasedAtMs: number; +}): Promise { + const terminalNotificationTypeFilter = sql`(${kilo_pass_store_events.payload_json}->>'notificationType') = '12'`; + const terminalTimestampMs = sql< + number | null + >`(${kilo_pass_store_events.payload_json}->>'eventTimeMillis')::double precision`; + + const terminalEvents = await db + .select({ + eventId: kilo_pass_store_events.event_id, + notificationType: sql< + string | null + >`${kilo_pass_store_events.payload_json}->>'notificationType'`, + terminalTimestampMs, + }) + .from(kilo_pass_store_events) + .where( + and( + eq(kilo_pass_store_events.payment_provider, KiloPassPaymentProvider.GooglePlay), + sql`${kilo_pass_store_events.processed_at} IS NOT NULL`, + terminalNotificationTypeFilter, + or( + eq(kilo_pass_store_events.provider_transaction_id, params.providerTransactionId), + and( + eq(kilo_pass_store_events.provider_subscription_id, params.purchaseToken), + sql`${terminalTimestampMs} >= ${params.purchasedAtMs}` + ) + ) + ) + ) + .limit(1); + + return terminalEvents[0] ?? null; +} + +export async function processGooglePlayKiloPassNotification(params: { + pubsubMessage: GooglePlayPubSubMessage; +}): Promise { + const { data, messageId } = params.pubsubMessage; + const developerNotification = decodeGooglePlayDeveloperNotification(data); + + if (developerNotification.packageName !== GOOGLE_PLAY_PACKAGE_NAME) { + throw new Error('Google Play notification package mismatch'); + } + + const subscriptionNotification = developerNotification.subscriptionNotification; + if (!subscriptionNotification) { + // One-time product and voided-purchase notifications carry no subscription + // lifecycle event that this handler manages. + return { processed: true }; + } + + const notificationType = subscriptionNotification.notificationType; + const purchaseToken = subscriptionNotification.purchaseToken; + + if (notificationType == null || purchaseToken == null) { + throw new Error('Google Play notification missing subscription identifiers'); + } + + const eventTimeMillis = developerNotification.eventTimeMillis ?? null; + const eventId = computeGooglePlayEventId({ + messageId, + purchaseToken, + notificationType, + eventTimeMillis, + }); + + const apiData = await getGooglePlaySubscriptionPurchase(purchaseToken); + const decoded = decodeGooglePlaySubscriptionPurchase(apiData, purchaseToken); + + const claim = await claimGooglePlayStoreEventForProcessing({ + eventId, + notificationType, + packageName: developerNotification.packageName, + eventTimeMillis, + purchaseToken, + latestOrderId: decoded.latestOrderId, + appAccountToken: decoded.obfuscatedExternalAccountId ?? null, + productId: decoded.productId || 'unknown', + environment: decoded.environment, + }); + if (claim === 'already_processed') { + return { processed: true, status: 'already_processed' }; + } + if (claim === 'in_flight') { + return { processed: false, status: 'in_flight' }; + } + + const isPurchaseType = PURCHASE_TYPES.has(notificationType); + const isExpired = decoded.expiryTimeMs <= Date.now(); + + if (isPurchaseType) { + if (isExpired) { + await markGooglePlayStoreEventProcessed(eventId); + return { processed: true }; + } + + const purchase = mapGooglePlayKiloPassPurchase(decoded); + + const terminalEvent = await findProcessedTerminalStoreEventForGooglePlayPurchase({ + purchaseToken, + providerTransactionId: purchase.providerTransactionId, + purchasedAtMs: decoded.startTimeMs, + }); + if (terminalEvent) { + await db.transaction(async tx => { + await appendKiloPassAuditLog(tx, { + action: KiloPassAuditLogAction.StoreNotificationReceived, + result: KiloPassAuditLogResult.Success, + payload: { + messageId: messageId ?? null, + notificationType, + providerSubscriptionId: purchaseToken, + providerTransactionId: purchase.providerTransactionId, + skippedStorePurchaseCompletion: true, + terminalEventId: terminalEvent.eventId, + terminalNotificationType: terminalEvent.notificationType, + terminalTimestampMs: terminalEvent.terminalTimestampMs, + }, + }); + await tx + .update(kilo_pass_store_events) + .set({ processed_at: new Date().toISOString() }) + .where( + and( + eq(kilo_pass_store_events.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_events.event_id, eventId) + ) + ); + }); + return { processed: true }; + } + + const user = await getUserForGooglePlayRenewal({ + providerSubscriptionId: purchase.providerSubscriptionId, + appAccountToken: purchase.appAccountToken, + }); + if (!user) { + if (notificationType !== GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_PURCHASED) { + throw new Error( + 'Google Play renewal notification cannot create a subscription without a user' + ); + } + await markGooglePlayStoreEventProcessed(eventId); + return { processed: true }; + } + + let completionResult: CompleteStoreKiloPassPurchaseResult | null = null; + let purchaseMismatch = false; + await db.transaction(async tx => { + try { + completionResult = await completeStoreKiloPassPurchase({ dbOrTx: tx, user, purchase }); + } catch (error) { + // A permanent provider/user mismatch settles `failed` inside the + // completion, so this event must be marked processed and never retried. + if (isStorePurchaseMismatchError(error)) { + purchaseMismatch = true; + await tx + .update(kilo_pass_store_events) + .set({ processed_at: new Date().toISOString() }) + .where( + and( + eq(kilo_pass_store_events.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_events.event_id, eventId) + ) + ); + return; + } + throw error; + } + await appendKiloPassAuditLog(tx, { + action: KiloPassAuditLogAction.StoreSubscriptionRenewed, + result: KiloPassAuditLogResult.Success, + kiloUserId: user.id, + payload: { + messageId: messageId ?? null, + providerSubscriptionId: purchase.providerSubscriptionId, + }, + }); + await tx + .update(kilo_pass_store_events) + .set({ processed_at: new Date().toISOString() }) + .where( + and( + eq(kilo_pass_store_events.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_events.event_id, eventId) + ) + ); + }); + if (purchaseMismatch) { + return { processed: true }; + } + // Post-commit only — never capture inside the transaction. + const trackedResult = completionResult as CompleteStoreKiloPassPurchaseResult | null; + if (trackedResult && !trackedResult.alreadyProcessed) { + await runAfterResponse(async () => { + trackKiloPassPurchaseCompleted({ + channel: 'google_play', + distinctId: user.google_user_email, + userId: user.id, + tier: trackedResult.tier, + cadence: trackedResult.cadence, + purchaseKind: trackedResult.purchaseKind, + providerTransactionId: purchase.providerTransactionId, + productId: purchase.productId, + environment: purchase.environment, + }); + }); + } + return { processed: true }; + } + + if (notificationType === GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_CANCELED) { + await markGooglePlaySubscriptionCancelingAtPeriodEnd(purchaseToken); + await appendKiloPassAuditLog(db, { + action: KiloPassAuditLogAction.StoreSubscriptionCanceled, + result: KiloPassAuditLogResult.Success, + payload: { + messageId: messageId ?? null, + providerSubscriptionId: purchaseToken, + }, + }); + await markGooglePlayStoreEventProcessed(eventId); + return { processed: true }; + } + + if (notificationType === GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_EXPIRED) { + await markGooglePlaySubscriptionEnded(db, purchaseToken); + await appendKiloPassAuditLog(db, { + action: KiloPassAuditLogAction.StoreSubscriptionExpired, + result: KiloPassAuditLogResult.Success, + payload: { + messageId: messageId ?? null, + providerSubscriptionId: purchaseToken, + }, + }); + await markGooglePlayStoreEventProcessed(eventId); + return { processed: true }; + } + + if (notificationType === GOOGLE_PLAY_NOTIFICATION_TYPE.SUBSCRIPTION_REVOKED) { + await db.transaction(async tx => { + let reversal: CreditReversalResult | null = null; + try { + reversal = await reverseGooglePlayRefundCredits(tx, purchaseToken, decoded.latestOrderId); + } catch (error) { + captureException(error, { + tags: { area: 'kilo-pass', operation: 'reverse-google-play-refund-credits' }, + extra: { + purchaseToken, + latestOrderId: decoded.latestOrderId, + }, + }); + } + await markGooglePlaySubscriptionEnded(tx, purchaseToken); + await appendKiloPassAuditLog(tx, { + action: KiloPassAuditLogAction.StoreSubscriptionRefunded, + result: KiloPassAuditLogResult.Success, + payload: { + messageId: messageId ?? null, + providerSubscriptionId: purchaseToken, + providerTransactionId: decoded.latestOrderId, + storePurchaseFound: reversal?.storePurchaseFound ?? false, + creditTransactionIds: reversal?.creditTransactionIds ?? [], + totalReversalMicrodollars: reversal?.totalReversalMicrodollars ?? 0, + reversedItemKinds: reversal?.reversedItemKinds ?? [], + }, + }); + await tx + .update(kilo_pass_store_events) + .set({ processed_at: new Date().toISOString() }) + .where( + and( + eq(kilo_pass_store_events.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_events.event_id, eventId) + ) + ); + }); + return { processed: true }; + } + + // Unknown subscription notification types (deferred, paused, price change, etc.): + // claim the event and mark it processed without acting. + await markGooglePlayStoreEventProcessed(eventId); + return { processed: true }; +} diff --git a/apps/web/src/lib/kilo-pass/posthog-tracking.test.ts b/apps/web/src/lib/kilo-pass/posthog-tracking.test.ts index 5ea22c3aea..a93f09bb14 100644 --- a/apps/web/src/lib/kilo-pass/posthog-tracking.test.ts +++ b/apps/web/src/lib/kilo-pass/posthog-tracking.test.ts @@ -73,6 +73,35 @@ describe('Kilo Pass PostHog tracking', () => { }); }); + it('captures google_play purchase completed with snake_case wire properties', () => { + trackKiloPassPurchaseCompleted({ + channel: 'google_play', + distinctId: 'user@example.com', + userId: 'user-789', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + purchaseKind: 'renewal', + providerTransactionId: 'GPA.1234', + productId: 'kilopass_tier49', + environment: 'Sandbox', + }); + + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: 'user@example.com', + event: 'kilo_pass_purchase_completed', + properties: { + channel: 'google_play', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + purchase_kind: 'renewal', + user_id: 'user-789', + provider_transaction_id: 'GPA.1234', + product_id: 'kilopass_tier49', + environment: 'Sandbox', + }, + }); + }); + it('captures stripe purchase completed with snake_case wire properties', () => { trackKiloPassPurchaseCompleted({ channel: 'stripe', diff --git a/apps/web/src/lib/kilo-pass/posthog-tracking.ts b/apps/web/src/lib/kilo-pass/posthog-tracking.ts index fc8ca48ac0..d60ff51e43 100644 --- a/apps/web/src/lib/kilo-pass/posthog-tracking.ts +++ b/apps/web/src/lib/kilo-pass/posthog-tracking.ts @@ -24,7 +24,7 @@ type TrackKiloPassPurchaseCompletedBase = { export type TrackKiloPassPurchaseCompletedParams = | (TrackKiloPassPurchaseCompletedBase & { - channel: 'app_store'; + channel: 'app_store' | 'google_play'; providerTransactionId: string; productId: string; environment: string; @@ -55,19 +55,19 @@ export function trackKiloPassPurchaseCompleted(params: TrackKiloPassPurchaseComp }; const properties = - params.channel === 'app_store' + params.channel === 'stripe' ? { - ...baseProperties, - provider_transaction_id: params.providerTransactionId, - product_id: params.productId, - environment: params.environment, - } - : { ...baseProperties, stripe_invoice_id: params.stripeInvoiceId, amount_paid_usd: params.amountPaidUsd, currency: params.currency, livemode: params.livemode, + } + : { + ...baseProperties, + provider_transaction_id: params.providerTransactionId, + product_id: params.productId, + environment: params.environment, }; try { From b909b8cc57d374c89876295d7c3034c3c50af983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 27 Aug 2026 15:06:47 +0200 Subject: [PATCH 2/5] fix(kilo-pass): stop sending Play purchase token to Sentry --- apps/web/src/lib/kilo-pass/google-play-notifications.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/lib/kilo-pass/google-play-notifications.ts b/apps/web/src/lib/kilo-pass/google-play-notifications.ts index 58379165a4..f8851a7e8c 100644 --- a/apps/web/src/lib/kilo-pass/google-play-notifications.ts +++ b/apps/web/src/lib/kilo-pass/google-play-notifications.ts @@ -779,7 +779,6 @@ export async function processGooglePlayKiloPassNotification(params: { captureException(error, { tags: { area: 'kilo-pass', operation: 'reverse-google-play-refund-credits' }, extra: { - purchaseToken, latestOrderId: decoded.latestOrderId, }, }); From 699555b4f52b5eecd938d0ae1ce3e5dc0dc9d18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 27 Aug 2026 18:26:50 +0200 Subject: [PATCH 3/5] fix(kilo-pass): type Play notification test mock --- apps/web/src/lib/kilo-pass/google-play-notifications.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts b/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts index 5335a47ae2..1e94e916a0 100644 --- a/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts +++ b/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts @@ -17,7 +17,7 @@ import { KiloPassIssuanceItemKind, KiloPassPaymentProvider } from './enums'; import type * as GooglePlayNotifications from './google-play-notifications'; import { toMicrodollars } from '@/lib/utils'; -const mockGetGooglePlaySubscriptionPurchase = jest.fn(); +const mockGetGooglePlaySubscriptionPurchase = jest.fn, [string]>(); jest.mock('./google-play-sdk', () => ({ getGooglePlaySubscriptionPurchase: mockGetGooglePlaySubscriptionPurchase, From 99cb3489eb77a8bd60d1bc61f54e78682ade4f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 27 Aug 2026 18:28:53 +0200 Subject: [PATCH 4/5] fix(kilo-pass): correct Play notification mock generic --- apps/web/src/lib/kilo-pass/google-play-notifications.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts b/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts index 1e94e916a0..66e20a7d56 100644 --- a/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts +++ b/apps/web/src/lib/kilo-pass/google-play-notifications.test.ts @@ -17,7 +17,7 @@ import { KiloPassIssuanceItemKind, KiloPassPaymentProvider } from './enums'; import type * as GooglePlayNotifications from './google-play-notifications'; import { toMicrodollars } from '@/lib/utils'; -const mockGetGooglePlaySubscriptionPurchase = jest.fn, [string]>(); +const mockGetGooglePlaySubscriptionPurchase = jest.fn<(purchaseToken: string) => Promise>(); jest.mock('./google-play-sdk', () => ({ getGooglePlaySubscriptionPurchase: mockGetGooglePlaySubscriptionPurchase, From 7072951c2666c7ca5f9ac0e93033344d1e7de1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 27 Aug 2026 22:08:20 +0200 Subject: [PATCH 5/5] fix(kilo-pass): verify Play RTDN service-account email --- ENVIRONMENT.md | 1 + .../play/notifications/route.test.ts | 64 ++++++++++++++++++- .../api/kilo-pass/play/notifications/route.ts | 13 +++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index fcbcfe3b7c..a20923a413 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -166,6 +166,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set = { 'content-type': 'application/json' }; @@ -46,8 +47,14 @@ describe('POST /api/kilo-pass/play/notifications', () => { beforeEach(() => { jest.clearAllMocks(); mockProcess.mockResolvedValue({ processed: true }); - mockVerifyIdToken.mockResolvedValue({}); - mockGetEnvVariable.mockReturnValue(AUDIENCE); + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => ({ email: SERVICE_ACCOUNT_EMAIL, email_verified: true }), + }); + mockGetEnvVariable.mockImplementation(name => { + if (name === 'GOOGLE_PLAY_RTDN_PUSH_AUDIENCE') return AUDIENCE; + if (name === 'GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL') return SERVICE_ACCOUNT_EMAIL; + return ''; + }); }); it('returns 401 when the Authorization bearer is missing', async () => { @@ -73,7 +80,58 @@ describe('POST /api/kilo-pass/play/notifications', () => { }); it('returns 401 when GOOGLE_PLAY_RTDN_PUSH_AUDIENCE is not configured', async () => { - mockGetEnvVariable.mockReturnValue(''); + mockGetEnvVariable.mockImplementation(name => + name === 'GOOGLE_PLAY_RTDN_PUSH_AUDIENCE' ? '' : SERVICE_ACCOUNT_EMAIL + ); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + it('returns 401 when GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL is not configured', async () => { + mockGetEnvVariable.mockImplementation(name => + name === 'GOOGLE_PLAY_RTDN_PUSH_AUDIENCE' ? AUDIENCE : '' + ); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockVerifyIdToken).not.toHaveBeenCalled(); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + it('returns 401 when the token email does not match the service account', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ + getPayload: () => ({ email: 'attacker@example.com', email_verified: true }), + }); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + it('returns 401 when the token email is not verified', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ + getPayload: () => ({ email: SERVICE_ACCOUNT_EMAIL, email_verified: false }), + }); + + const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockProcess).not.toHaveBeenCalled(); + }); + + it('returns 401 when the token payload lacks an email', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ + getPayload: () => ({ email_verified: true }), + }); const response = await POST(request({ message: { data: 'payload' } }, 'valid-token')); diff --git a/apps/web/src/app/api/kilo-pass/play/notifications/route.ts b/apps/web/src/app/api/kilo-pass/play/notifications/route.ts index fd4bc217d2..9f1c21569b 100644 --- a/apps/web/src/app/api/kilo-pass/play/notifications/route.ts +++ b/apps/web/src/app/api/kilo-pass/play/notifications/route.ts @@ -17,8 +17,19 @@ async function verifyGooglePlayRtdnToken(idToken: string): Promise { if (!audience) { throw new Error('GOOGLE_PLAY_RTDN_PUSH_AUDIENCE is not set'); } + const expectedEmail = getEnvVariable('GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL'); + if (!expectedEmail) { + throw new Error('GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL is not set'); + } const client = new OAuth2Client(); - await client.verifyIdToken({ idToken, audience }); + const ticket = await client.verifyIdToken({ idToken, audience }); + const payload = ticket.getPayload(); + if (!payload?.email || payload.email_verified !== true) { + throw new Error('Play RTDN push token email is not verified'); + } + if (payload.email !== expectedEmail) { + throw new Error('Play RTDN push token email does not match the configured service account'); + } } export async function POST(request: Request) {