From fe6bb56577ba2c4249cac6e01a4420223050203f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 27 Aug 2026 12:48:07 +0200 Subject: [PATCH 1/2] feat(commerce): add Play Kilo Pass presentation flag --- .../kilo-pass/purchase-presentation.test.ts | 32 +++++++++ .../lib/kilo-pass/purchase-presentation.ts | 2 + .../commerce/purchase-presentation.test.ts | 71 ++++++++++++++++++- .../src/commerce/purchase-presentation.ts | 29 ++++++-- 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/kilo-pass/purchase-presentation.test.ts b/apps/web/src/lib/kilo-pass/purchase-presentation.test.ts index 16ca3c906e..d77f49a08c 100644 --- a/apps/web/src/lib/kilo-pass/purchase-presentation.test.ts +++ b/apps/web/src/lib/kilo-pass/purchase-presentation.test.ts @@ -50,6 +50,38 @@ describe('buildPurchasePresentation', () => { expect(result.webUrl).toBe(`${APP_URL}/subscriptions/kilo-pass`); }); + it('maps Android Play Kilo Pass with native support and no sub to native_iap', () => { + const result = buildPurchasePresentation({ + subscription: null, + input: { + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + }, + }); + + expect(result.kind).toBe('native_iap'); + expect(result.cta).toEqual({ label: null, action: 'none' }); + expect(result.webUrl).toBeNull(); + }); + + it('maps Android Play Kilo Pass with native support and a live Stripe sub to native_iap', () => { + const result = buildPurchasePresentation({ + subscription: stripeSub('active'), + input: { + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + }, + }); + + expect(result.kind).toBe('native_iap'); + expect(result.cta).toEqual({ label: null, action: 'none' }); + expect(result.webUrl).toBeNull(); + }); + it('maps Android credits to web_management with the credits web URL', () => { const result = buildPurchasePresentation({ subscription: null, diff --git a/apps/web/src/lib/kilo-pass/purchase-presentation.ts b/apps/web/src/lib/kilo-pass/purchase-presentation.ts index 8c30547e7a..afe67de2fc 100644 --- a/apps/web/src/lib/kilo-pass/purchase-presentation.ts +++ b/apps/web/src/lib/kilo-pass/purchase-presentation.ts @@ -35,6 +35,7 @@ export type PurchasePresentationInput = { storefront: PurchaseStorefront | null | undefined; product: PurchaseProduct; program?: string | null; + supportsNativePlayKiloPass?: boolean; }; type SubscriptionForPresentation = { @@ -70,6 +71,7 @@ export function buildPurchasePresentation(params: { product: input.product, program: input.program, hasStripeManagedPass, + supportsNativePlayKiloPass: input.supportsNativePlayKiloPass, }); const cta = diff --git a/packages/app-shared/src/commerce/purchase-presentation.test.ts b/packages/app-shared/src/commerce/purchase-presentation.test.ts index cca04a70e7..2a2bfff699 100644 --- a/packages/app-shared/src/commerce/purchase-presentation.test.ts +++ b/packages/app-shared/src/commerce/purchase-presentation.test.ts @@ -72,6 +72,62 @@ describe('resolvePurchasePresentation', () => { }); }); + it('marks Android Play Kilo Pass with native support and no Stripe as native_iap with NO_CTA', () => { + const result = resolvePurchasePresentation({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + hasStripeManagedPass: false, + }); + + expect(result.kind).toBe('native_iap'); + expect(result.cta).toEqual({ action: 'none', webPath: null }); + }); + + it('marks Android Play Kilo Pass with native support and a Stripe sub as native_iap', () => { + const result = resolvePurchasePresentation({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: true, + hasStripeManagedPass: true, + }); + + expect(result.kind).toBe('native_iap'); + expect(result.cta).toEqual({ action: 'none', webPath: null }); + }); + + it('keeps Android Play Kilo Pass without native support and no Stripe unavailable', () => { + const result = resolvePurchasePresentation({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: false, + hasStripeManagedPass: false, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.reason).toBe('kilo_pass_not_available_on_android'); + expect(result.cta).toEqual({ action: 'none', webPath: null }); + }); + + it('keeps Android Play Kilo Pass without native support and Stripe as web_management', () => { + const result = resolvePurchasePresentation({ + platform: 'android', + storefront: 'play', + product: 'kilo_pass', + supportsNativePlayKiloPass: false, + hasStripeManagedPass: true, + }); + + expect(result.kind).toBe('web_management'); + expect(result.cta).toEqual({ + action: 'open_web', + webPath: '/subscriptions/kilo-pass', + }); + }); + it('marks a missing platform as unavailable with no CTA', () => { const result = resolvePurchasePresentation({ platform: null, @@ -163,10 +219,13 @@ describe('mapKiloPassStatusToClass', () => { }); describe('isNativeIapMutationAllowed', () => { - it('is true only for ios + app_store + kilo_pass', () => { + it('is true for iOS App Store and Android Play Kilo Pass', () => { expect( isNativeIapMutationAllowed({ platform: 'ios', storefront: 'app_store', product: 'kilo_pass' }) ).toBe(true); + expect( + isNativeIapMutationAllowed({ platform: 'android', storefront: 'play', product: 'kilo_pass' }) + ).toBe(true); }); it('is false for iOS credits', () => { @@ -175,9 +234,15 @@ describe('isNativeIapMutationAllowed', () => { ).toBe(false); }); - it('is false for Android Kilo Pass', () => { + it('is false for Android credits', () => { expect( - isNativeIapMutationAllowed({ platform: 'android', storefront: 'play', product: 'kilo_pass' }) + isNativeIapMutationAllowed({ platform: 'android', storefront: 'play', product: 'credits' }) + ).toBe(false); + }); + + it('is false for Android Kilo Pass on a non-Play storefront', () => { + expect( + isNativeIapMutationAllowed({ platform: 'android', storefront: 'web', product: 'kilo_pass' }) ).toBe(false); }); diff --git a/packages/app-shared/src/commerce/purchase-presentation.ts b/packages/app-shared/src/commerce/purchase-presentation.ts index b37c2cc244..fdaa9321eb 100644 --- a/packages/app-shared/src/commerce/purchase-presentation.ts +++ b/packages/app-shared/src/commerce/purchase-presentation.ts @@ -91,6 +91,12 @@ export type ResolvePurchasePresentationInput = { * ended. `unpaid` is ended, so it is false. */ hasStripeManagedPass: boolean; + /** + * Old Android omits the field or sends false and keeps unavailable or Stripe + * web_management. New Android sends true and gets native_iap. Remove the + * field when every Android client mounts Play IAP. + */ + supportsNativePlayKiloPass?: boolean; }; const NO_CTA: PurchasePresentationCta = { action: 'none', webPath: null }; @@ -108,7 +114,7 @@ function webCta(webPath: string): PurchasePresentationCta { export function resolvePurchasePresentation( input: ResolvePurchasePresentationInput ): PurchasePresentation { - const { platform, storefront, product, hasStripeManagedPass } = input; + const { platform, storefront, product, hasStripeManagedPass, supportsNativePlayKiloPass } = input; const program = input.program ?? null; // Credits are not sold in the iOS app. @@ -121,12 +127,22 @@ export function resolvePurchasePresentation( return { kind: 'web_management', reason: null, cta: webCta('/credits'), program }; } - // Kilo Pass native IAP is allowed only for iOS App Store. + // Kilo Pass native IAP is allowed for iOS App Store. if (product === 'kilo_pass' && platform === 'ios' && storefront === 'app_store') { return { kind: 'native_iap', reason: null, cta: NO_CTA, program }; } - // Android Kilo Pass is never native IAP. + // Kilo Pass native IAP is allowed for Android Play when the client mounts Play IAP. + if ( + product === 'kilo_pass' && + platform === 'android' && + storefront === 'play' && + supportsNativePlayKiloPass === true + ) { + return { kind: 'native_iap', reason: null, cta: NO_CTA, program }; + } + + // Android Kilo Pass without native Play IAP support. if (product === 'kilo_pass' && platform === 'android') { if (hasStripeManagedPass) { return { @@ -178,7 +194,7 @@ export function mapKiloPassStatusToClass( } /** - * True only for iOS App Store Kilo Pass, the single native IAP combination. + * True for iOS App Store Kilo Pass and for Android Play Kilo Pass. */ export function isNativeIapMutationAllowed(input: { platform: PurchasePlatform | null | undefined; @@ -186,6 +202,9 @@ export function isNativeIapMutationAllowed(input: { product: PurchaseProduct; }): boolean { return ( - input.platform === 'ios' && input.storefront === 'app_store' && input.product === 'kilo_pass' + (input.platform === 'ios' && + input.storefront === 'app_store' && + input.product === 'kilo_pass') || + (input.platform === 'android' && input.storefront === 'play' && input.product === 'kilo_pass') ); } From d9e78022fdbdae28993153455746022491112211 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:14:55 +0200 Subject: [PATCH 2/2] fix(kilo-pass): restrict Apple completion to App Store --- apps/web/src/routers/kilo-pass-router.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index eacac0ca48..54e3e2e2a1 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -93,7 +93,6 @@ import { PURCHASE_PRODUCTS, PURCHASE_STATUS_CLASSES, PURCHASE_STOREFRONTS, - isNativeIapMutationAllowed, } from '@kilocode/app-shared/commerce'; import { verifyAppleKiloPassTransactionJws } from '@/lib/kilo-pass/apple-store-verifier'; import { completeStoreKiloPassPurchase } from '@/lib/kilo-pass/store-subscription-completion'; @@ -1286,12 +1285,12 @@ export const kiloPassRouter = createTRPCRouter({ ) .output(CompleteStorePurchaseOutputSchema) .mutation(async ({ ctx, input }) => { + // `isNativeIapMutationAllowed` now also admits Android Play. This mutation stays + // App Store, so require the exact App Store combination. if ( - !isNativeIapMutationAllowed({ - platform: input.platform, - storefront: input.storefront, - product: input.product, - }) + input.platform !== 'ios' || + input.storefront !== 'app_store' || + input.product !== 'kilo_pass' ) { throw new TRPCError({ code: 'FORBIDDEN',