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/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 05f7b1ad33..1d2c3c42ba 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -94,7 +94,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'; @@ -1287,12 +1286,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', 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') ); }