Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/web/src/lib/kilo-pass/purchase-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/kilo-pass/purchase-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type PurchasePresentationInput = {
storefront: PurchaseStorefront | null | undefined;
product: PurchaseProduct;
program?: string | null;
supportsNativePlayKiloPass?: boolean;
Comment thread
iscekic marked this conversation as resolved.
};

type SubscriptionForPresentation = {
Expand Down Expand Up @@ -70,6 +71,7 @@ export function buildPurchasePresentation(params: {
product: input.product,
program: input.program,
hasStripeManagedPass,
supportsNativePlayKiloPass: input.supportsNativePlayKiloPass,
});

const cta =
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/routers/kilo-pass-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
71 changes: 68 additions & 3 deletions packages/app-shared/src/commerce/purchase-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
});

Expand Down
29 changes: 24 additions & 5 deletions packages/app-shared/src/commerce/purchase-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -178,14 +194,17 @@ 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;
storefront: PurchaseStorefront | null | undefined;
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')
);
}