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
15 changes: 15 additions & 0 deletions apps/maple-research/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,21 @@ not exposed on macOS:
nix develop --no-update-lock-file .#android -c just android-build
```

### Android subscription access

Android is consumption-only: accounts can use existing subscriptions, team
access, API credits and subscription passes acquired elsewhere. The app does
not offer subscription checkout, credit purchases, paid upgrades, or billing
portal links that can start purchases. `/pricing` shows plan information without
mounting legacy checkout effects, including after an old `selected_plan` callback.
Payment API helpers also reject Android calls before requesting a checkout or
portal. This is an Android platform rule for all users, independent of app review
or server version flags; web, desktop and iOS keep their existing purchase flows.

Android may explain that plans are managed on the website without linking to
checkout. Subscription support remains available by email. Google explicitly
allows [consumption-only apps and purchase communication outside the app](https://support.google.com/googleplay/android-developer/answer/10281818).

The CI scripts under `scripts/ci/` are the authority for PR and release-shaped
platform builds. Some are platform-specific, remove build outputs or
`node_modules`, and deliberately ignore local `.env*` files in favor of fixed
Expand Down
76 changes: 76 additions & 0 deletions apps/maple-research/frontend/src/billing/androidPurchasing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import * as platform from "@/utils/platform";
import {
createCheckoutSession,
createZapriteCheckoutSession,
createZapriteUpgrade,
createZapriteUpgradeQuote,
fetchApiCreditBalance,
fetchBillingStatus,
fetchPortalUrl,
fetchTeamStatus,
openValidatedCheckoutUrl,
purchaseApiCredits,
purchaseApiCreditsZaprite
} from "./billingApi";

describe("Android consumption-only billing boundary", () => {
let android: ReturnType<typeof spyOn<typeof platform, "isAndroid">>;
let request: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;

beforeEach(() => {
android = spyOn(platform, "isAndroid").mockReturnValue(true);
request = spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async () => Response.json({ product_name: "Pro", can_chat: true, balance: 12345 }),
{ preconnect: globalThis.fetch.preconnect }
)
);
});
afterEach(() => {
request.mockRestore();
android.mockRestore();
});

const blocked: Array<[string, () => Promise<unknown>]> = [
["card checkout", () => createCheckoutSession("token", "a@example.test", "pro", "/", "/")],
["Bitcoin checkout", () => createZapriteCheckoutSession("token", "a@example.test", "pro", "/")],
["card portal", () => fetchPortalUrl("token")],
["Bitcoin quote", () => createZapriteUpgradeQuote("token", "max")],
["Bitcoin upgrade", () => createZapriteUpgrade("token", "quote", "idempotency-key")],
["existing checkout URL", () => openValidatedCheckoutUrl("https://pay.zaprite.com/checkout")],
[
"card credits",
() =>
purchaseApiCredits("token", {
credits: 10000,
email: "a@example.test",
success_url: "/",
cancel_url: "/"
})
],
[
"Bitcoin credits",
() =>
purchaseApiCreditsZaprite("token", {
credits: 10000,
email: "a@example.test",
success_url: "/"
})
]
];

for (const [name, action] of blocked) {
test(`${name} is blocked before requesting or opening payment`, async () => {
await expect(action()).rejects.toThrow("Purchases are not available in the Android app.");
expect(request).not.toHaveBeenCalled();
});
}

test("existing subscription, credits and team reads still reach billing", async () => {
expect((await fetchBillingStatus("token")).can_chat).toBe(true);
expect((await fetchApiCreditBalance("token")).balance).toBe(12345);
await fetchTeamStatus("token");
expect(request).toHaveBeenCalledTimes(3);
});
});
17 changes: 16 additions & 1 deletion apps/maple-research/frontend/src/billing/billingApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isMobile, isTauri } from "@/utils/platform";
import { isAndroid, isMobile, isTauri } from "@/utils/platform";
import {
isAllowedZapriteCheckoutUrl,
parseBillingApiError,
Expand All @@ -12,6 +12,13 @@ export const MIN_PURCHASE_CREDITS = 10000;
export const MIN_PURCHASE_AMOUNT = 10;
export const MIN_PURCHASE_ERROR = `Minimum purchase is ${MIN_PURCHASE_CREDITS.toLocaleString()} credits ($${MIN_PURCHASE_AMOUNT})`;

/** Android consumes existing access; checkout and portals can offer new purchases. */
function assertPurchasingAvailable(): void {
if (isAndroid()) {
throw new Error("Purchases are not available in the Android app.");
}
}

export type BillingPaymentProvider = "stripe" | "zaprite" | "subscription_pass" | "apple";

/** Management is independent of the provider selected to supply the current quota. */
Expand Down Expand Up @@ -167,6 +174,7 @@ export async function fetchProducts(version?: string): Promise<BillingProduct[]>
}

export async function fetchPortalUrl(thirdPartyToken: string): Promise<string> {
assertPurchasingAvailable();
let returnUrl = window.location.origin;

// For all Tauri platforms, use the actual website origin instead of tauri://localhost
Expand Down Expand Up @@ -213,6 +221,7 @@ export async function createCheckoutSession(
quantity?: number,
assertCurrent?: () => void
): Promise<void> {
assertPurchasingAvailable();
const requestBody = {
email,
product_id: productId,
Expand Down Expand Up @@ -290,6 +299,7 @@ export async function createZapriteCheckoutSession(
quantity?: number,
assertCurrent?: () => void
): Promise<void> {
assertPurchasingAvailable();
const requestBody = {
email,
product_id: productId,
Expand Down Expand Up @@ -373,6 +383,7 @@ export async function createZapriteUpgradeQuote(
thirdPartyToken: string,
targetProductId: string
): Promise<ZapriteUpgradeQuote> {
assertPurchasingAvailable();
const response = await fetch(
`${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/zaprite/upgrade-quotes`,
{
Expand All @@ -396,6 +407,7 @@ export async function createZapriteUpgrade(
idempotencyKey: string,
successUrl?: string
): Promise<ZapriteUpgradeCreateResponse> {
assertPurchasingAvailable();
const response = await fetch(
`${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/zaprite/upgrades`,
{
Expand Down Expand Up @@ -441,6 +453,7 @@ export async function fetchZapriteUpgradeStatus(
}

export async function openValidatedCheckoutUrl(checkoutUrl: string): Promise<void> {
assertPurchasingAvailable();
if (!isAllowedZapriteCheckoutUrl(checkoutUrl)) {
throw new Error("Checkout URL is not from the expected payment provider");
}
Expand Down Expand Up @@ -860,6 +873,7 @@ export async function purchaseApiCredits(
thirdPartyToken: string,
data: PurchaseCreditsRequest
): Promise<CheckoutResponse> {
assertPurchasingAvailable();
const response = await fetch(
`${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-credits/purchase`,
{
Expand Down Expand Up @@ -891,6 +905,7 @@ export async function purchaseApiCreditsZaprite(
thirdPartyToken: string,
data: PurchaseCreditsZapriteRequest
): Promise<CheckoutResponse> {
assertPurchasingAvailable();
const response = await fetch(
`${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-credits/purchase-zaprite`,
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import { Children, isValidElement, type ReactNode } from "react";
import * as platform from "@/utils/platform";
import { Route as PrivacyRoute } from "@/routes/privacy";
import { Route as TermsRoute } from "@/routes/terms";
import { Footer } from "./Footer";

function destinations(node: ReactNode): string[] {
return Children.toArray(node).flatMap((child) => {
if (!isValidElement<{ href?: string; to?: string; children?: ReactNode }>(child)) return [];
const destination = child.props.href ?? child.props.to;
return [...(destination ? [destination] : []), ...destinations(child.props.children)];
});
}

describe("Android information navigation", () => {
const cleanup: Array<() => void> = [];
function setAndroid(value: boolean) {
const androidSpy = spyOn(platform, "isAndroid").mockReturnValue(value);
const tauriSpy = spyOn(platform, "isTauri").mockReturnValue(value);
cleanup.push(
() => androidSpy.mockRestore(),
() => tauriSpy.mockRestore()
);
}
afterEach(() => {
cleanup
.splice(0)
.reverse()
.forEach((restore) => restore());
});

test("footer retains local legal pages and email support without marketing exits", () => {
setAndroid(true);
expect(destinations(Footer())).toEqual(["/privacy", "/terms", "mailto:support@trymaple.ai"]);
});

test("web footer preserves marketing and community navigation", () => {
setAndroid(false);
const links = destinations(Footer());
expect(links).toContain("/pricing");
expect(links).toContain("https://blog.trymaple.ai");
expect(links).toContain("https://discord.gg/ch2gjZAMGy");
});

for (const [name, route] of [
["Privacy", PrivacyRoute],
["Terms", TermsRoute]
] as const) {
test(`${name} keeps the website reference unlinked on Android`, () => {
const component = route.options.component as () => ReactNode;
setAndroid(true);
expect(destinations(component())).not.toContain("https://trymaple.ai");
});
test(`${name} preserves the website link outside Android`, () => {
const component = route.options.component as () => ReactNode;
setAndroid(false);
expect(destinations(component())).toContain("https://trymaple.ai");
});
}
});
12 changes: 11 additions & 1 deletion apps/maple-research/frontend/src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@ import { Link } from "@tanstack/react-router";
import { DiscordIcon } from "./icons/DiscordIcon";
import { MARKETING_DOWNLOADS_URL } from "@/config/domains";
import { openExternalUrl } from "@/utils/openUrl";
import { isTauri } from "@/utils/platform";
import { isAndroid, isTauri } from "@/utils/platform";
import { GITHUB_REPOSITORY_URL } from "@/utils/githubRelease";

export function Footer() {
const isTauriPlatform = isTauri();

if (isAndroid()) {
return (
<footer className="flex flex-wrap justify-center gap-4 border-t py-8 text-sm text-muted-foreground">
<Link to="/privacy">Privacy Policy</Link>
<Link to="/terms">Terms of Service</Link>
<a href="mailto:support@trymaple.ai">Contact support</a>
</footer>
);
}

return (
<div className="w-full dark:bg-[hsl(var(--background))] bg-[hsl(var(--footer-bg))] py-16 border-t border-[hsl(var(--marketing-card-border))]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { getBillingService } from "@/billing/billingService";
import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
import { beginAllChatRuntimeDeletionFence } from "@/services/chatRuntimeDeletionFence";
import { assertChatAccountCredential } from "@/services/chatAccountCredential";
import { isIOS } from "@/utils/platform";
import { isAndroid, isIOS } from "@/utils/platform";

interface GuestPaymentWarningDialogProps {
open: boolean;
Expand Down Expand Up @@ -145,9 +145,11 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa
Your anonymous account is not activated yet and cannot use the chat feature.
</p>
<p className="text-sm text-muted-foreground">
{isIOS()
? "You can subscribe with Apple or restore an existing Apple purchase. Keep your Maple Account ID and password so you can sign in again."
: "To start chatting with Maple AI, you need to subscribe to a paid plan. Anonymous accounts must pay for a full year using Bitcoin or redeem a subscription pass."}
{isAndroid()
? "Purchases are not available in the Android app. Sign in with a Maple account that already has a subscription, including one purchased on the web, or redeem an existing subscription pass."
: isIOS()
? "You can subscribe with Apple or restore an existing Apple purchase. Keep your Maple Account ID and password so you can sign in again."
: "To start chatting with Maple AI, you need to subscribe to a paid plan. Anonymous accounts must pay for a full year using Bitcoin or redeem a subscription pass."}
</p>
</div>

Expand All @@ -160,7 +162,7 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa
<div className="space-y-2">
<Button onClick={handleGoToPricing} className="w-full gap-2">
<CreditCard className="w-4 h-4" />
View Pricing & Subscribe
{isAndroid() ? "View plan details" : "View Pricing & Subscribe"}
</Button>
<Button
variant="outline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { AlertTriangle } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { isIOS } from "@/utils/platform";
import { isAndroid, isIOS } from "@/utils/platform";

interface GuestSignupWarningDialogProps {
open: boolean;
Expand All @@ -28,6 +28,7 @@ export function GuestSignupWarningDialog({
const [noSupportAgreed, setNoSupportAgreed] = useState(false);
const [backupCredentialsAgreed, setBackupCredentialsAgreed] = useState(false);
const applePayments = isIOS();
const androidConsumptionOnly = isAndroid();

const allAgreed = bitcoinPaymentAgreed && noSupportAgreed && backupCredentialsAgreed;

Expand Down Expand Up @@ -70,7 +71,14 @@ export function GuestSignupWarningDialog({
htmlFor="bitcoin-payment"
className="text-sm font-medium leading-relaxed cursor-pointer break-words"
>
{applePayments ? (
{androidConsumptionOnly ? (
<>
I understand that anonymous accounts require paid access to chat.{" "}
<strong>Purchases are not available in the Android app.</strong> I can sign in
with an existing subscribed Maple account or redeem an existing subscription
pass.
</>
) : applePayments ? (
<>
I understand that Apple subscriptions are linked to this Maple account.{" "}
<strong>
Expand Down
Loading
Loading