Skip to content
Merged
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
128 changes: 126 additions & 2 deletions apps/maple-research/frontend/src/billing/applePricingPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, test } from "bun:test";
import type { BillingStatus } from "./billingApi";
import type { BillingProduct, BillingStatus } from "./billingApi";
import {
appleProductIds,
applePurchaseConflict,
appleSizes,
currentApplePlan,
currentAppleSize,
hasAppleSubscriptionToManage,
hasConfirmedBillingStatus,
monthlyAppleProducts
monthlyAppleProducts,
monthlyAppleSizeProducts
} from "./applePricingPolicy";
import type { StoreKitProduct } from "@/services/storeKitService";

Expand Down Expand Up @@ -141,4 +144,125 @@ describe("iOS pricing policy", () => {
).toBe(false);
}
});

// A plan billing lists beyond the paywall's two cards. The names are
// fixtures: this app learns such a plan only from billing.
const studioId = "app.example.studio.monthly";
function listed(overrides: Partial<BillingProduct> & Pick<BillingProduct, "id" | "name">) {
return {
description: "",
active: true,
default_price: { id: "price", currency: "usd", unit_amount: 0, recurring: null },
...overrides
} as BillingProduct;
}
const billingList: BillingProduct[] = [
listed({
id: "prod_pro",
name: "Pro",
plan_key: "pro",
plan_group: "individual",
plan_rank: 1,
monthly_credits: 20_000,
apple_product_id: ids.Pro
}),
listed({
id: "prod_max",
name: "Max",
plan_key: "max",
plan_group: "individual",
plan_rank: 2,
monthly_credits: 200_000,
apple_product_id: ids.Max
}),
listed({
id: "prod_studio",
name: "Max Studio",
plan_key: "studio",
plan_group: "individual",
plan_rank: 3,
display_name: "Studio",
monthly_credits: 400_000,
apple_product_id: studioId
}),
listed({ id: "prod_team", name: "Team", plan_key: "team", plan_group: "team", plan_rank: null })
];
const studio = appleSizes(billingList, ids).Max?.[0];

test("a plan billing lists with an App Store product becomes a size of the plan below it", () => {
expect(appleSizes(billingList, ids)).toEqual({
Max: [
{
productId: studioId,
planKey: "studio",
catalogName: "Max Studio",
displayName: "Studio",
credits: "20× Pro's monthly credits"
}
]
});
// Not a multiple of Pro: the allowance is stated as it is.
const odd = billingList.map((product) =>
product.id === "prod_studio" ? { ...product, monthly_credits: 250_000 } : product
);
expect(appleSizes(odd, ids).Max?.[0].credits).toBe("250,000 monthly credits");
});

test("nothing is offered without billing's list, its plan fields or an App Store product of its own", () => {
expect(appleSizes(undefined, ids)).toEqual({});
expect(appleSizes(billingList, null)).toEqual({});
// An older billing server sends names only.
expect(
appleSizes(
billingList.map(({ id, name, description, active, default_price }) => ({
id,
name,
description,
active,
default_price
})),
ids
)
).toEqual({});
const withStudio = (change: Partial<BillingProduct>) =>
billingList.map((product) =>
product.id === "prod_studio" ? { ...product, ...change } : product
);
expect(appleSizes(withStudio({ apple_product_id: undefined }), ids)).toEqual({});
// It may not reuse the identifier of the card it would sit on.
expect(appleSizes(withStudio({ apple_product_id: ids.Max }), ids)).toEqual({});
expect(appleSizes(withStudio({ active: false }), ids)).toEqual({});
// A larger size needs its plan to be on sale.
expect(
appleSizes(
billingList.filter((product) => product.id !== "prod_max" && product.id !== "prod_pro"),
ids
)
).toEqual({});
});

test("a size's App Store product passes the same checks as a plan's", () => {
const valid: StoreKitProduct = { ...pro, productId: studioId, formattedPrice: "$229.99" };
expect(monthlyAppleSizeProducts([pro, valid], [studioId])).toEqual({ [studioId]: valid });
expect(monthlyAppleSizeProducts([pro], [studioId])).toEqual({});
expect(monthlyAppleSizeProducts([valid, valid], [studioId])).toEqual({});
expect(monthlyAppleSizeProducts([{ ...valid, subscriptionPeriod: "P1Y" }], [studioId])).toEqual(
{}
);
});

test("only an Apple subscriber on that plan is on a size", () => {
const apple: BillingStatus = { ...free, payment_provider: "apple", product_name: "Max Studio" };
expect(currentAppleSize({ ...apple, plan_key: "studio" }, studio!)).toBe(true);
// An older billing server reports the name only.
expect(currentAppleSize(apple, studio!)).toBe(true);
expect(currentAppleSize({ ...apple, plan_key: "max", product_name: "Max" }, studio!)).toBe(
false
);
expect(currentAppleSize({ ...apple, payment_provider: "stripe" }, studio!)).toBe(false);
expect(currentAppleSize({ ...apple, is_subscribed: false }, studio!)).toBe(false);
expect(currentAppleSize(undefined, studio!)).toBe(false);
// The plan's own card is not current for a subscriber on the larger size.
expect(currentApplePlan({ ...apple, plan_key: "studio" }, "Max")).toBe(false);
});
});
112 changes: 103 additions & 9 deletions apps/maple-research/frontend/src/billing/applePricingPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import type { BillingStatus } from "./billingApi";
import type { BillingProduct, BillingStatus } from "./billingApi";
import { productDisplayName, productsByCard, type PlanCard } from "./plan";
import type { StoreKitProduct } from "@/services/storeKitService";

export type ApplePlan = "Pro" | "Max";

/**
* A larger size of one of the paywall's plans. Billing names its App Store
* product, so a plan added later is offered without an identifier here.
*/
export type AppleSize = {
productId: string;
planKey: string | undefined;
/** Billing's `name` for the plan: what `product_name` reports for a subscriber. */
catalogName: string;
displayName: string;
/** The plan's allowance in the paywall's wording, when billing gave it. */
credits: string | null;
};

/** A size the App Store returned a valid monthly product for. */
export type AppleSizeOffer = AppleSize & { product: StoreKitProduct };

const paywallCards: readonly (PlanCard & { name: ApplePlan })[] = [
{ name: "Pro", planKey: "pro", rank: 1 },
{ name: "Max", planKey: "max", rank: 2 }
];

// App Store product identifiers are public and belong to distinct app records.
// The legacy /products API contains Stripe prices, not an Apple catalog.
export function appleProductIds(variant: string | undefined): Record<ApplePlan, string> | null {
Expand All @@ -11,21 +34,86 @@ export function appleProductIds(variant: string | undefined): Record<ApplePlan,
return { Pro: `${bundle}.pro.monthly`, Max: `${bundle}.max.monthly` };
}

function monthlyProduct(
products: readonly StoreKitProduct[],
productId: string
): StoreKitProduct | undefined {
const matches = products.filter((product) => product.productId === productId);
const product = matches.length === 1 ? matches[0] : undefined;
return product?.subscriptionPeriod === "P1M" &&
product.formattedPrice.trim() &&
/^[A-Z]{3}$/.test(product.priceCurrencyCode)
? product
: undefined;
}

export function monthlyAppleProducts(
products: readonly StoreKitProduct[],
ids: Record<ApplePlan, string>
): Partial<Record<ApplePlan, StoreKitProduct>> {
const result: Partial<Record<ApplePlan, StoreKitProduct>> = {};
for (const plan of ["Pro", "Max"] as const) {
const matches = products.filter((product) => product.productId === ids[plan]);
const product = matches.length === 1 ? matches[0] : undefined;
if (
product?.subscriptionPeriod === "P1M" &&
product.formattedPrice.trim() &&
/^[A-Z]{3}$/.test(product.priceCurrencyCode)
) {
result[plan] = product;
const product = monthlyProduct(products, ids[plan]);
if (product) result[plan] = product;
}
return result;
}

/** The same validation for the sizes billing lists, keyed by App Store product. */
export function monthlyAppleSizeProducts(
products: readonly StoreKitProduct[],
productIds: readonly string[]
): Record<string, StoreKitProduct> {
const result: Record<string, StoreKitProduct> = {};
for (const productId of productIds) {
const product = monthlyProduct(products, productId);
if (product) result[productId] = product;
}
return result;
}

function sizeCredits(product: BillingProduct, proCredits: number | undefined): string | null {
const credits = product.monthly_credits;
if (typeof credits !== "number" || credits <= 0) return null;
const multiple = proCredits && credits % proCredits === 0 ? credits / proCredits : 0;
return multiple > 1
? `${multiple}× Pro's monthly credits`
: `${credits.toLocaleString("en-US")} monthly credits`;
}

/**
* The larger sizes billing lists for the paywall's plans. A plan with an App
* Store product and no card of its own is offered by the highest plan below
* it. Pro and Max keep their fixed identifiers: the paywall has to work when
* the product list cannot be loaded.
*/
export function appleSizes(
products: readonly BillingProduct[] | undefined,
ids: Record<ApplePlan, string> | null
): Partial<Record<ApplePlan, AppleSize[]>> {
if (!products || !ids) return {};
const taken = new Set<string>(Object.values(ids));
const proCredits = products.find((product) => product.plan_key === "pro")?.monthly_credits;
const result: Partial<Record<ApplePlan, AppleSize[]>> = {};
const offered = productsByCard(paywallCards, products);
for (const card of paywallCards) {
const sizes: AppleSize[] = [];
for (const product of offered.get(card.name) ?? []) {
const own = product.plan_key
? product.plan_key === card.planKey
: product.name.trim().toLowerCase() === card.name.toLowerCase();
const productId = product.apple_product_id?.trim();
if (own || !productId || taken.has(productId)) continue;
taken.add(productId);
sizes.push({
productId,
planKey: product.plan_key,
catalogName: product.name,
displayName: productDisplayName(product),
credits: sizeCredits(product, proCredits)
});
}
if (sizes.length) result[card.name] = sizes;
}
return result;
}
Expand Down Expand Up @@ -68,6 +156,12 @@ export function currentApplePlan(status: BillingStatus | undefined, plan: AppleP
);
}

export function currentAppleSize(status: BillingStatus | undefined, size: AppleSize): boolean {
if (status?.payment_provider !== "apple" || !status.is_subscribed) return false;
if (status.plan_key && size.planKey) return status.plan_key === size.planKey;
return status.product_name?.trim().toLowerCase() === size.catalogName.trim().toLowerCase();
}

/** Prevent cross-provider checkout when Apple supplies access or may renew again. */
export function hasAppleSubscriptionToManage(status: BillingStatus | undefined): boolean {
return (
Expand Down
2 changes: 2 additions & 0 deletions apps/maple-research/frontend/src/billing/billingApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export type BillingProduct = {
display_name?: string;
/** Plan credits each month; per seat for Team. */
monthly_credits?: number;
/** The App Store product that sells this plan monthly, when billing maps one. */
apple_product_id?: string;
};

// Discount Types
Expand Down
46 changes: 43 additions & 3 deletions apps/maple-research/frontend/src/billing/useAppleProducts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ describe("Apple price lifecycle", () => {
let change!: (country: string | null) => void;
let removed = 0;
const requests: ReturnType<typeof deferred<StoreKitProduct[]>>[] = [];
const asked: string[][] = [];
const visibility = Object.assign(new EventTarget(), {
visibilityState: "visible" as DocumentVisibilityState
});
const events = { visibility, focus: new EventTarget() };
let view!: ReturnType<typeof useAppleProducts>;
const bridge: Pick<StoreKitBridge, "getProducts" | "onStorefront"> = {
getProducts: () => {
getProducts: (productIds) => {
const request = deferred<StoreKitProduct[]>();
requests.push(request);
asked.push(productIds);
return request.promise;
},
onStorefront: async (callback) => {
Expand All @@ -49,13 +51,14 @@ describe("Apple price lifecycle", () => {
};
}
};
function Harness() {
view = useAppleProducts("dev", bridge, events);
function Harness({ sizeIds }: { sizeIds?: readonly string[] }) {
view = useAppleProducts("dev", bridge, events, sizeIds);
return null;
}
return {
Harness,
requests,
asked,
events,
change: () => change("FRA"),
get view() {
Expand All @@ -66,6 +69,43 @@ describe("Apple price lifecycle", () => {
}
};
}
test("sizes billing lists are requested with the plans; one the App Store lacks is not an error", async () => {
const sizeId = "app.example.studio.monthly";
const max = { ...product, productId: "cloud.opensecret.maple.dev.max.monthly" };
const f = fixture();
await act(async () => {
renderer = create(<f.Harness sizeIds={[sizeId]} />);
});
expect(f.asked[0]).toEqual([product.productId, max.productId, sizeId]);
await act(async () => {
f.requests[0].resolve([product, max, { ...product, productId: sizeId }]);
});
expect(f.view.sizes[sizeId]?.productId).toBe(sizeId);
expect(f.view.error).toBeNull();
// The same list in a new array does not reload prices.
await act(async () => {
renderer!.update(<f.Harness sizeIds={[sizeId]} />);
});
expect(f.requests).toHaveLength(1);
// The list arriving later (or changing) loads again, with the plans.
await act(async () => {
renderer!.update(<f.Harness sizeIds={[]} />);
});
expect(f.asked[1]).toEqual([product.productId, max.productId]);
await act(async () => {
f.requests[1].resolve([product, max]);
});
expect(f.view.sizes).toEqual({});
await act(async () => {
renderer!.update(<f.Harness sizeIds={[sizeId]} />);
});
await act(async () => {
f.requests[2].resolve([product, max]);
});
expect(f.view.sizes).toEqual({});
expect(f.view.products.Max?.productId).toBe(max.productId);
expect(f.view.error).toBeNull();
});
test("a storefront change immediately removes old prices and rejects an older completion", async () => {
const f = fixture();
await act(async () => {
Expand Down
Loading
Loading