diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index 390b59bb37..e0f84df584 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -100,6 +100,10 @@ type AppStoreVerifierMock = { verifyAppleKiloPassTransactionJws: ReturnType; }; +type GooglePlayVerifierMock = { + verifyGooglePlayKiloPassPurchase: ReturnType; +}; + type StoreCompletionMock = { completeStoreKiloPassPurchase: ReturnType; }; @@ -122,6 +126,10 @@ function getAppStoreVerifierMock(): AppStoreVerifierMock { return jest.requireMock('@/lib/kilo-pass/apple-store-verifier') as AppStoreVerifierMock; } +function getGooglePlayVerifierMock(): GooglePlayVerifierMock { + return jest.requireMock('@/lib/kilo-pass/google-play-verifier') as GooglePlayVerifierMock; +} + function getStoreCompletionMock(): StoreCompletionMock { return jest.requireMock('@/lib/kilo-pass/store-subscription-completion') as StoreCompletionMock; } @@ -146,6 +154,7 @@ type KiloPassCaller = { storefront?: 'app_store' | 'play' | 'web' | null; product: 'credits' | 'kilo_pass'; program?: string | null; + supportsNativePlayKiloPass?: boolean; }) => Promise<{ kind: 'native_iap' | 'web_management' | 'unavailable'; statusClass: 'healthy' | 'pending' | 'retryable' | 'terminal' | 'inactive'; @@ -163,6 +172,9 @@ type KiloPassCaller = { storefront: 'app_store' | 'play' | 'web'; product: 'credits' | 'kilo_pass'; program?: string | null; + supportsNativePlayKiloPass?: boolean; + googleProductId?: string; + googlePurchaseToken?: string | null; appleProductId: string; appleOriginalTransactionId?: string | null; }) => Promise<{ @@ -189,6 +201,18 @@ type KiloPassCaller = { cadence: KiloPassCadence; alreadyProcessed: boolean; }>; + completePlayPurchase: (input: { + purchaseToken: string; + platform: 'android' | 'ios'; + storefront: 'app_store' | 'play' | 'web'; + product: 'credits' | 'kilo_pass'; + program?: string | null; + }) => Promise<{ + subscriptionId: string; + tier: KiloPassTier; + cadence: KiloPassCadence; + alreadyProcessed: boolean; + }>; getState: () => Promise<{ subscription: { stripeSubscriptionId: string | null; @@ -405,6 +429,10 @@ jest.mock('@/lib/kilo-pass/apple-store-verifier', () => ({ verifyAppleKiloPassTransactionJws: jest.fn(), })); +jest.mock('@/lib/kilo-pass/google-play-verifier', () => ({ + verifyGooglePlayKiloPassPurchase: jest.fn(), +})); + jest.mock('@/lib/kilo-pass/store-subscription-completion', () => ({ completeStoreKiloPassPurchase: jest.fn(), })); @@ -489,6 +517,27 @@ function appStorePurchaseFixture( }; } +function googlePlayPurchaseFixture( + overrides: Partial = {} +): ValidatedStoreKiloPassPurchase { + return { + paymentProvider: KiloPassPaymentProvider.GooglePlay, + productId: 'kilopass_tier19', + providerTransactionId: 'GPA.router-test-order', + providerOriginalTransactionId: 'play-router-test-token', + providerSubscriptionId: 'play-router-test-token', + appAccountToken: crypto.randomUUID(), + purchaseToken: 'play-router-test-token', + environment: 'Sandbox', + purchasedAtIso: '2026-05-01T00:00:00.000Z', + expiresAtIso: '2026-06-01T00:00:00.000Z', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + rawPayload: {}, + ...overrides, + }; +} + function expectNoStripeManagementCalls(stripeMock: StripeMock): void { expect(stripeMock.billingPortal.sessions.create).not.toHaveBeenCalled(); expect(stripeMock.subscriptions.retrieve).not.toHaveBeenCalled(); @@ -698,6 +747,7 @@ describe('kiloPassRouter', () => { stripeMock.invoices.voidInvoice.mockReset(); stripeMock.invoices.retrieve.mockReset(); getAppStoreVerifierMock().verifyAppleKiloPassTransactionJws.mockReset(); + getGooglePlayVerifierMock().verifyGooglePlayKiloPassPurchase.mockReset(); getStoreCompletionMock().completeStoreKiloPassPurchase.mockReset(); getPosthogTrackingMock().trackKiloPassPurchaseCompleted.mockReset(); getSentryMock().captureException.mockReset(); @@ -968,6 +1018,194 @@ describe('kiloPassRouter', () => { }) ).rejects.toThrow('commerce_not_available'); }); + + it('rejects an Android Play combination on the App Store completion mutation', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect( + caller.kiloPass.completeAppStorePurchase({ + signedTransactionJws: 'signed-jws', + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + }) + ).rejects.toThrow('commerce_not_available'); + }); + }); + + describe('completePlayPurchase', () => { + it('completes a Google Play purchase and feeds GooglePlay to the completion service', async () => { + const verifierMock = getGooglePlayVerifierMock(); + const completionMock = getStoreCompletionMock(); + const trackingMock = getPosthogTrackingMock(); + const sentryMock = getSentryMock(); + const user = await insertTestUser(); + const purchase = googlePlayPurchaseFixture({ + appAccountToken: user.app_store_account_token, + }); + verifierMock.verifyGooglePlayKiloPassPurchase.mockResolvedValue(purchase); + const completionResult = { + subscriptionId: 'sub-play-test-id', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + alreadyProcessed: false as const, + purchaseKind: 'initial' as const, + }; + const expectedClientResult = { + subscriptionId: completionResult.subscriptionId, + tier: completionResult.tier, + cadence: completionResult.cadence, + alreadyProcessed: false, + }; + completionMock.completeStoreKiloPassPurchase.mockResolvedValue(completionResult); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloPass.completePlayPurchase({ + purchaseToken: 'play-router-test-token', + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + }); + + expect(result).toEqual(expectedClientResult); + expect(verifierMock.verifyGooglePlayKiloPassPurchase).toHaveBeenCalledWith( + 'play-router-test-token' + ); + expect(completionMock.completeStoreKiloPassPurchase).toHaveBeenCalledTimes(1); + expect(completionMock.completeStoreKiloPassPurchase).toHaveBeenCalledWith({ + user: expect.objectContaining({ id: user.id }), + purchase, + }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith({ + channel: 'google_play', + distinctId: user.google_user_email, + userId: user.id, + tier: completionResult.tier, + cadence: completionResult.cadence, + purchaseKind: 'initial', + providerTransactionId: purchase.providerTransactionId, + productId: purchase.productId, + environment: purchase.environment, + }); + expect(sentryMock.captureException).not.toHaveBeenCalled(); + }); + + it('rejects the Play completion mutation when the platform is ios', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect( + caller.kiloPass.completePlayPurchase({ + purchaseToken: 'play-router-test-token', + platform: 'ios', + storefront: 'app_store', + product: 'kilo_pass', + }) + ).rejects.toThrow('commerce_not_available'); + }); + + it('keeps Google Play account mismatch copy stable and does not log it as an internal failure', async () => { + const verifierMock = getGooglePlayVerifierMock(); + const completionMock = getStoreCompletionMock(); + const sentryMock = getSentryMock(); + verifierMock.verifyGooglePlayKiloPassPurchase.mockResolvedValue(googlePlayPurchaseFixture()); + + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect( + caller.kiloPass.completePlayPurchase({ + purchaseToken: 'play-router-test-token', + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + }) + ).rejects.toThrow('Google Play purchase account token does not match the signed-in user.'); + expect(completionMock.completeStoreKiloPassPurchase).not.toHaveBeenCalled(); + expect(sentryMock.captureException).not.toHaveBeenCalled(); + }); + + it('throws a distinct error when appAccountToken is null and does not log it as an internal failure', async () => { + const verifierMock = getGooglePlayVerifierMock(); + const completionMock = getStoreCompletionMock(); + const sentryMock = getSentryMock(); + verifierMock.verifyGooglePlayKiloPassPurchase.mockResolvedValue( + googlePlayPurchaseFixture({ appAccountToken: null }) + ); + + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect( + caller.kiloPass.completePlayPurchase({ + purchaseToken: 'play-router-test-token', + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + }) + ).rejects.toThrow( + "This Google Play purchase isn't linked to your Kilo account. Make sure you're signed in to the Google account that made the purchase, then try again." + ); + expect(completionMock.completeStoreKiloPassPurchase).not.toHaveBeenCalled(); + expect(sentryMock.captureException).not.toHaveBeenCalled(); + }); + + it('does not track when completeStoreKiloPassPurchase reports alreadyProcessed', async () => { + const verifierMock = getGooglePlayVerifierMock(); + const completionMock = getStoreCompletionMock(); + const trackingMock = getPosthogTrackingMock(); + const user = await insertTestUser(); + verifierMock.verifyGooglePlayKiloPassPurchase.mockResolvedValue( + googlePlayPurchaseFixture({ + appAccountToken: user.app_store_account_token, + }) + ); + completionMock.completeStoreKiloPassPurchase.mockResolvedValue({ + subscriptionId: 'sub-play-test-id', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + alreadyProcessed: true, + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloPass.completePlayPurchase({ + purchaseToken: 'play-router-test-token', + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + }); + + expect(result).toEqual({ + subscriptionId: 'sub-play-test-id', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + alreadyProcessed: true, + }); + expect(trackingMock.trackKiloPassPurchaseCompleted).not.toHaveBeenCalled(); + }); + + it('maps verifier failures to mobile-safe copy', async () => { + const verifierMock = getGooglePlayVerifierMock(); + const sentryMock = getSentryMock(); + verifierMock.verifyGooglePlayKiloPassPurchase.mockRejectedValue( + new Error('Google Play Kilo Pass product is not enabled') + ); + + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + await expect( + caller.kiloPass.completePlayPurchase({ + purchaseToken: 'play-router-test-token', + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + }) + ).rejects.toThrow('We could not verify this Google Play purchase. Please try again.'); + expect(sentryMock.captureException).toHaveBeenCalledTimes(1); + }); }); describe('getPurchasePresentation', () => { @@ -1049,6 +1287,24 @@ describe('kiloPassRouter', () => { expect(result.cta).toEqual({ label: 'Manage', action: 'open_web' }); expect(result.webUrl).toContain('/subscriptions/kilo-pass'); }); + + it('returns native_iap for Android Play Kilo Pass when the client mounts Play IAP', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + const result = await caller.kiloPass.getPurchasePresentation({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + }); + + expect(result.kind).toBe('native_iap'); + expect(result.statusClass).toBe('inactive'); + expect(result.reason).toBeNull(); + expect(result.cta).toEqual({ label: null, action: 'none' }); + expect(result.webUrl).toBeNull(); + }); }); describe('preflightPurchase', () => { @@ -1280,6 +1536,139 @@ describe('kiloPassRouter', () => { reason: 'already_subscribed', }); }); + + it('allows an Android Play purchase with the flag and a valid Google product id', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + const result = await caller.kiloPass.preflightPurchase({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + googleProductId: 'kilopass_tier19', + appleProductId: 'kilopass.tier19.monthly.v1', + }); + + expect(result).toEqual({ allowed: true, statusClass: 'healthy', reason: null }); + }); + + it('allows a live Google Play subscription on android+play (upgrade path)', async () => { + const user = await insertTestUser(); + await insertSubscription({ + kiloUserId: user.id, + paymentProvider: KiloPassPaymentProvider.GooglePlay, + providerSubscriptionId: 'gpa_preflight_play_owned', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + }); + const caller = await createCallerForUser(user.id); + + const result = await caller.kiloPass.preflightPurchase({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + googleProductId: 'kilopass_tier19', + appleProductId: 'kilopass.tier19.monthly.v1', + }); + + expect(result).toEqual({ allowed: true, statusClass: 'healthy', reason: null }); + }); + + it('rejects an unknown Google product id', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + + const result = await caller.kiloPass.preflightPurchase({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + googleProductId: 'unknown.google.product', + appleProductId: 'kilopass.tier19.monthly.v1', + }); + + expect(result).toEqual({ + allowed: false, + statusClass: 'terminal', + reason: 'unknown_product', + }); + }); + + it('blocks a live Stripe subscription for an Android Play purchase', async () => { + const user = await insertTestUser(); + await insertSubscription({ + kiloUserId: user.id, + stripeSubscriptionId: 'sub_test_preflight_play_stripe', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + }); + const caller = await createCallerForUser(user.id); + + const result = await caller.kiloPass.preflightPurchase({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + googleProductId: 'kilopass_tier19', + appleProductId: 'kilopass.tier19.monthly.v1', + }); + + expect(result).toEqual({ + allowed: false, + statusClass: 'terminal', + reason: 'already_subscribed', + }); + }); + + it("blocks a Play purchase when this device's token belongs to another Kilo account", async () => { + const owner = await insertTestUser(); + const buyer = await insertTestUser(); + const providerSubscriptionId = `gpa_${crypto.randomUUID()}`; + const { id: subscriptionId } = await insertSubscription({ + kiloUserId: owner.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + paymentProvider: KiloPassPaymentProvider.GooglePlay, + providerSubscriptionId, + }); + await db.insert(kilo_pass_store_purchases).values({ + kilo_pass_subscription_id: subscriptionId, + kilo_user_id: owner.id, + payment_provider: KiloPassPaymentProvider.GooglePlay, + product_id: 'kilopass_tier19', + provider_subscription_id: providerSubscriptionId, + provider_transaction_id: `tx_${crypto.randomUUID()}`, + provider_original_transaction_id: providerSubscriptionId, + app_account_token: owner.app_store_account_token, + purchase_token: providerSubscriptionId, + environment: 'Sandbox', + purchased_at: '2026-01-01T00:00:00.000Z', + expires_at: '2026-02-01T00:00:00.000Z', + raw_payload_json: {}, + }); + + const caller = await createCallerForUser(buyer.id); + const result = await caller.kiloPass.preflightPurchase({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + googleProductId: 'kilopass_tier19', + appleProductId: 'kilopass.tier19.monthly.v1', + googlePurchaseToken: providerSubscriptionId, + }); + + expect(result).toEqual({ + allowed: false, + statusClass: 'terminal', + reason: 'owned_by_another_account', + }); + }); }); describe('getState', () => { diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 54e3e2e2a1..9cc082fb94 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -82,6 +82,7 @@ import { abandonCollectibleInvoicesForStripeSubscription } from '@/lib/kilo-pass import { getAllMobileStoreKiloPassProducts, getMobileStoreKiloPassProductByAppleProductId, + getMobileStoreKiloPassProductByGoogleProductId, } from '@/lib/kilo-pass/mobile-store-products'; import { buildPurchasePresentation, @@ -93,8 +94,10 @@ import { PURCHASE_PRODUCTS, PURCHASE_STATUS_CLASSES, PURCHASE_STOREFRONTS, + isNativeIapMutationAllowed, } from '@kilocode/app-shared/commerce'; import { verifyAppleKiloPassTransactionJws } from '@/lib/kilo-pass/apple-store-verifier'; +import { verifyGooglePlayKiloPassPurchase } from '@/lib/kilo-pass/google-play-verifier'; import { completeStoreKiloPassPurchase } from '@/lib/kilo-pass/store-subscription-completion'; import { trackKiloPassPurchaseCompleted } from '@/lib/kilo-pass/posthog-tracking'; import { @@ -296,6 +299,29 @@ function assertAppStoreAccountTokenMatchesUser(params: { } } +const GOOGLE_PLAY_ACCOUNT_TOKEN_MISMATCH_MESSAGE = + 'Google Play purchase account token does not match the signed-in user.'; +const GOOGLE_PLAY_PURCHASE_NOT_LINKED_TO_ACCOUNT_MESSAGE = + "This Google Play purchase isn't linked to your Kilo account. Make sure you're signed in to the Google account that made the purchase, then try again."; + +function assertGooglePlayAccountTokenMatchesUser(params: { + appAccountToken: string | null; + userAppStoreAccountToken: string; +}): void { + if (params.appAccountToken === null) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: GOOGLE_PLAY_PURCHASE_NOT_LINKED_TO_ACCOUNT_MESSAGE, + }); + } + if (params.appAccountToken !== params.userAppStoreAccountToken) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: GOOGLE_PLAY_ACCOUNT_TOKEN_MISMATCH_MESSAGE, + }); + } +} + function mapAppStoreCompletionError(error: unknown, userId: string): TRPCError { if (error instanceof TRPCError) { if (error.code === 'CONFLICT') { @@ -345,6 +371,57 @@ function mapAppStoreCompletionError(error: unknown, userId: string): TRPCError { }); } +function mapPlayCompletionError(error: unknown, userId: string): TRPCError { + if (error instanceof TRPCError) { + if (error.code === 'CONFLICT') { + return new TRPCError({ + code: 'CONFLICT', + message: 'Purchase is still being processed — try again in a moment.', + }); + } + return error; + } + + captureException(error, { + tags: { + area: 'kilo-pass', + operation: 'complete-play-purchase', + }, + extra: { + kiloUserId: userId, + }, + }); + + const message = error instanceof Error ? error.message : ''; + const isVerifierFailure = + message.startsWith('Google Play ') || + message.includes('transaction') || + message.includes('product'); + const isDomainFailure = + message.includes('already belongs') || + message.includes('already have an active Kilo Pass subscription') || + message.includes('previous period expiration'); + + if (isVerifierFailure) { + return new TRPCError({ + code: 'BAD_REQUEST', + message: 'We could not verify this Google Play purchase. Please try again.', + }); + } + + if (isDomainFailure) { + return new TRPCError({ + code: 'BAD_REQUEST', + message: 'This Google Play purchase cannot be used for your account.', + }); + } + + return new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'We could not finish this Google Play purchase. Please try again.', + }); +} + function isTwoMonthPromoOfferActive(): boolean { return dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF); } @@ -1030,6 +1107,11 @@ const GetPurchasePresentationInputSchema = z.object({ storefront: PurchaseStorefrontSchema.nullable().optional(), product: PurchaseProductSchema, program: z.string().max(64).nullable().optional(), + /** + * Old clients omit it. Omit or false keeps today's Android presentation. + * Remove when every Android client mounts Play IAP. + */ + supportsNativePlayKiloPass: z.boolean().optional(), }); const PreflightPurchaseInputSchema = z.object({ @@ -1037,6 +1119,13 @@ const PreflightPurchaseInputSchema = z.object({ storefront: PurchaseStorefrontSchema, product: PurchaseProductSchema, program: z.string().max(64).nullable().optional(), + supportsNativePlayKiloPass: z.boolean().optional(), + googleProductId: z.string().min(1).optional(), + /** + * Play purchase token this device already owns. Untrusted: it can only deny + * a purchase. Old clients omit it. + */ + googlePurchaseToken: z.string().min(1).max(256).nullable().optional(), appleProductId: z.string().min(1), /** * Original transaction ID of a Kilo Pass this device already owns, when StoreKit @@ -1215,6 +1304,7 @@ export const kiloPassRouter = createTRPCRouter({ storefront: input.storefront, product: input.product, program: input.program, + supportsNativePlayKiloPass: input.supportsNativePlayKiloPass, }); }), @@ -1230,6 +1320,7 @@ export const kiloPassRouter = createTRPCRouter({ storefront: input.storefront, product: input.product, program: input.program, + supportsNativePlayKiloPass: input.supportsNativePlayKiloPass, }, }); @@ -1241,7 +1332,14 @@ export const kiloPassRouter = createTRPCRouter({ }; } - if (!getMobileStoreKiloPassProductByAppleProductId(input.appleProductId)) { + if (input.storefront === 'play') { + if ( + !input.googleProductId || + !getMobileStoreKiloPassProductByGoogleProductId(input.googleProductId) + ) { + return { allowed: false, statusClass: 'terminal', reason: 'unknown_product' }; + } + } else if (!getMobileStoreKiloPassProductByAppleProductId(input.appleProductId)) { return { allowed: false, statusClass: 'terminal', reason: 'unknown_product' }; } @@ -1261,10 +1359,32 @@ export const kiloPassRouter = createTRPCRouter({ } } + // Same ownership guard for a Play purchase this device already owns. + if (input.googlePurchaseToken) { + const devicePurchase = await readDb.query.kilo_pass_store_purchases.findFirst({ + columns: { kilo_user_id: true }, + where: and( + eq(kilo_pass_store_purchases.payment_provider, KiloPassPaymentProvider.GooglePlay), + eq(kilo_pass_store_purchases.provider_subscription_id, input.googlePurchaseToken) + ), + }); + if (devicePurchase && devicePurchase.kilo_user_id !== ctx.user.id) { + return { allowed: false, statusClass: 'terminal', reason: 'owned_by_another_account' }; + } + } + + // Exclude the native provider for this storefront. Old iOS excluded App Store + // only. Play storefront excludes GooglePlay so a Play-owned pass is not treated + // as another provider. A live Stripe sub still returns `already_subscribed`. + const nativeStoreProvider = + input.storefront === 'play' + ? KiloPassPaymentProvider.GooglePlay + : KiloPassPaymentProvider.AppStore; + const hasLiveOtherProviderSub = subscription != null && !isStripeSubscriptionEnded(subscription.status) && - subscription.paymentProvider !== KiloPassPaymentProvider.AppStore; + subscription.paymentProvider !== nativeStoreProvider; if (hasLiveOtherProviderSub) { return { allowed: false, statusClass: 'terminal', reason: 'already_subscribed' }; @@ -1326,6 +1446,62 @@ export const kiloPassRouter = createTRPCRouter({ } }), + completePlayPurchase: baseProcedure + .input( + z.object({ + purchaseToken: z.string().min(1), + platform: PurchasePlatformSchema, + storefront: PurchaseStorefrontSchema, + product: PurchaseProductSchema, + program: z.string().max(64).nullable().optional(), + }) + ) + .output(CompleteStorePurchaseOutputSchema) + .mutation(async ({ ctx, input }) => { + // Play completion is Android-only. The shared helper also admits App Store, + // so require an Android platform in addition to its checks. + if ( + !isNativeIapMutationAllowed({ + platform: input.platform, + storefront: input.storefront, + product: input.product, + }) || + input.platform !== 'android' + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'commerce_not_available', + }); + } + try { + const purchase = await verifyGooglePlayKiloPassPurchase(input.purchaseToken); + assertGooglePlayAccountTokenMatchesUser({ + appAccountToken: purchase.appAccountToken, + userAppStoreAccountToken: ctx.user.app_store_account_token, + }); + const result = await completeStoreKiloPassPurchase({ + user: ctx.user, + purchase, + }); + if (!result.alreadyProcessed) { + trackKiloPassPurchaseCompleted({ + channel: 'google_play', + distinctId: ctx.user.google_user_email, + userId: ctx.user.id, + tier: result.tier, + cadence: result.cadence, + purchaseKind: result.purchaseKind, + providerTransactionId: purchase.providerTransactionId, + productId: purchase.productId, + environment: purchase.environment, + }); + } + return result; + } catch (error) { + throw mapPlayCompletionError(error, ctx.user.id); + } + }), + getAverageMonthlyUsageLast3Months: baseProcedure .output(GetAverageMonthlyUsageLast3MonthsOutputSchema) .query(async ({ ctx }) => {