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
58 changes: 46 additions & 12 deletions docs/CHECKOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Contents: [How the flow works](#how-the-flow-works-popup-vs-redirect) ·

`useCheckout` (and the two buttons built on it) runs one chain:
log in if needed, charge, add a card if the charge needs one, retry once.
Subscriptions differ after login: they open Hypery's hosted subscribe page
(see [Subscriptions: hosted subscribe page](#subscriptions-hosted-subscribe-page)).

| Step | `popup` mode | `redirect` mode |
| --- | --- | --- |
Expand All @@ -31,9 +33,40 @@ A card is added only when the charge returns `PAYMENT_METHOD_REQUIRED`, or a
`402` other than `PAYMENT_INCOMPLETE` / `PAYMENT_DECLINED`. Those two are
returned as errors because a new card will not fix them.

Purchases and subscriptions get an idempotency key generated once per
`checkout()` call and persisted with the redirect resume, so a resumed charge is
not billed twice.
Purchases get an idempotency key generated once per `checkout()` call and
persisted with the redirect resume, so a resumed charge is not billed twice.

### Subscriptions: hosted subscribe page

Requires a Hypery gateway with hosted subscribe sessions (hyperyai/hypery#193).
After making sure the user is logged in, `kind: 'subscription'`:

1. Creates a session: `POST /api/marketplace/subscribe-sessions` with
`{ planId, interval?, state }` and a fresh random `state`
(`crypto.getRandomValues`). Popup mode sends `returnOrigin` = the origin of
`config.redirectUri`; redirect mode sends `returnUrl` = the current page URL.
Both must match a redirect URI registered on your OAuth app.
2. Opens the returned `url`. On that page the user picks the **team** that owns
the subscription, the card and the interval (your `interval` is only the
preselection).
- popup (`hypery-subscribe`, 480x760): when the user is already logged in the
window is opened synchronously in the click (showing "Loading…") and pointed
at the session once it's created, so popup blockers allow it; it is closed if
session creation fails. If login comes first, the subscribe popup opens after
the login popup. Only a `hypery:subscribe` message
from the gateway origin with the matching `sessionId` and `state` is accepted.
Closing the popup returns `cancelled`.
- redirect (or a blocked popup): `{ sessionId, state, input }` is saved in
`localStorage` (`hypery_pending_subscribe`) and the page navigates away.
Hypery sends the user back with `?subscribe_session=&state=&subscribe_status=`;
on that load `useCheckout` checks `state` against storage, removes the params
with `history.replaceState`, fetches the result, and sets `status` and `lastResult`.
3. Fetches the authoritative outcome,
`GET /api/marketplace/subscribe-sessions/:id/result` (`SubscribeSessionResult`).
Only `status: 'completed'` is a success; `cancelled`, `pending` or `expired` give
`{ status: 'cancelled' }`. A state mismatch is an error with code `STATE_MISMATCH`.

On success `CheckoutResult.data` is `{ subscription: AppSubscription, team: { id, name } }`.

---

Expand Down Expand Up @@ -71,8 +104,8 @@ The props type is exported as `BuyButtonProps`.

## `SubscribeButton`

Subscribes the user to one of your app's plans (`kind: 'subscription'`), with the
same two-click confirmation. Never throws.
Subscribes the user to one of your app's plans (`kind: 'subscription'`) on
Hypery's hosted subscribe page, with the same two-click confirmation. Never throws.

```tsx
import { SubscribeButton, planPriceCents } from '@hyperyai/sdk';
Expand All @@ -87,11 +120,11 @@ const yearly = planPriceCents(plan, 'year');
| --- | --- | --- | --- |
| `planId` | `string` | required | Plan id from `useAppSubscription().plans`. |
| `priceCents` | `number` | none | Display only; Hypery charges the plan's real price. |
| `interval` | `'month' \| 'year'` | not sent (server defaults to monthly) | Billing interval; also shown as `/mo` or `/yr`. The plan must offer it, otherwise the error is `INTERVAL_NOT_OFFERED`. |
| `interval` | `'month' \| 'year'` | not sent (server defaults to monthly) | Interval preselected on the hosted page (the user can change it); also shown as `/mo` or `/yr`. |
| `label` | `ReactNode` | `Subscribe` + price | Idle label. |
| `requireConfirmation` | `boolean` | `true` | Two-click confirm. |
| `onSuccess` | `(data: any) => void` | none | Gateway response, `{ subscription, alreadySubscribed }`. |
| `onError` | `(error: ParsedError, result: CheckoutResult) => void` | none | Terminal errors. For 3-D Secure, `error.code === 'PAYMENT_INCOMPLETE'` and `result.data.error.clientSecret` / `stripeAccount` can be confirmed with Stripe.js. |
| `onSuccess` | `(data: any) => void` | none | `{ subscription, team }` from the session result. |
| `onError` | `(error: ParsedError, result: CheckoutResult) => void` | none | Terminal errors (session creation, result fetch, `STATE_MISMATCH`). Card entry and 3-D Secure happen on the hosted page. |
| `className` | `string` | `''` | Appended classes. |
| `branding` | `BrandingConfig` | none | `primaryColor` becomes the button background. |

Expand Down Expand Up @@ -120,14 +153,15 @@ if (r.status === 'success') refreshBalance();
| --- | --- | --- |
| `'purchase'` | `appId: string`, `amountCents: number`, `description?: string`, `idempotencyKey?: string` | `POST /api/marketplace/checkout` |
| `'topup'` | `usdAmount: number` | `POST /api/wallet/topup` |
| `'subscription'` | `planId: string`, `interval?: PlanInterval`, `idempotencyKey?: string` | `POST /api/marketplace/subscribe` |
| `'subscription'` | `planId: string`, `interval?: PlanInterval` (preselection), `idempotencyKey?` (deprecated, ignored) | `POST /api/marketplace/subscribe-sessions` + hosted page |

| Returns | Type | Description |
| --- | --- | --- |
| `checkout` | `(input: CheckoutInput) => Promise<CheckoutResult>` | Run the flow. |
| `status` | `CheckoutStatus` | `'idle' \| 'authenticating' \| 'charging' \| 'adding-card' \| 'redirecting' \| 'success' \| 'error' \| 'cancelled'`. |
| `status` | `CheckoutStatus` | `'idle' \| 'authenticating' \| 'charging' \| 'adding-card' \| 'subscribing' \| 'redirecting' \| 'success' \| 'error' \| 'cancelled'`. |
| `isRunning` | `boolean` | True for any status other than `idle`, `success`, `error`, `cancelled`. |
| `error` | `ParsedError \| null` | Last terminal error. |
| `lastResult` | `CheckoutResult \| null` | Last finished outcome, including a subscription resolved after a redirect return. |

`CheckoutResult`: `{ status: 'success' | 'error' | 'cancelled' | 'redirecting'; data?: any; error?: ParsedError }`.
On `error` from the gateway, `data` carries the raw response body. A second
Expand Down Expand Up @@ -213,7 +247,7 @@ function Pricing({ appId }: { appId: string }) {
| Returns | Type | Description |
| --- | --- | --- |
| `plans` | `AppPlan[]` | Active plans. |
| `subscriptions` | `AppSubscription[]` | All of the user's subscriptions to this app. |
| `subscriptions` | `AppSubscription[]` | The app's subscriptions across all of the user's teams (subscriptions are team-owned). |
| `activeSubscription` | `AppSubscription \| null` | First with status `active`, `trialing` or `past_due`. |
| `isSubscribed` | `boolean` | `!!activeSubscription`. |
| `remainingCreditUsd` | `number` | Sum of `remainingUsd` over the live subscription's grants. |
Expand All @@ -223,7 +257,7 @@ function Pricing({ appId }: { appId: string }) {
| `isLoading` | `boolean` | |
| `error` | `ParsedError \| null` | Last load/action error. |
| `refresh` | `() => Promise<void>` | Refetch both lists. |
| `subscribe` | `(planId, opts?: { idempotencyKey?, interval? }) => Promise<CheckoutResult>` | Runs `useCheckout`; refreshes on success. |
| `subscribe` | `(planId, opts?: { interval? }) => Promise<CheckoutResult>` | Runs `useCheckout` (hosted subscribe page; `interval` is the preselection); refreshes on success, including after a redirect return. |
| `cancel` | `(subscriptionId?: string) => Promise<boolean>` | Stop renewal at period end. Defaults to the live subscription. |
| `resume` | `(subscriptionId?: string) => Promise<boolean>` | Undo a pending cancellation. |
| `switchInterval` | `(subscriptionId: string \| undefined, interval: PlanInterval) => Promise<SwitchIntervalResult>` | See below. |
Expand Down
1 change: 1 addition & 0 deletions docs/TYPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Every type exported from `@hyperyai/sdk`, grouped, one line each. Import with
| Type | Description | Details |
| --- | --- | --- |
| `CheckoutInput` | `purchase` / `topup` / `subscription` union. | [CHECKOUT.md](./CHECKOUT.md#usecheckout) |
| `SubscribeSessionResult` | Authoritative hosted subscribe session outcome (`status`, `state`, `team`, `subscription`). | [CHECKOUT.md](./CHECKOUT.md#subscriptions-hosted-subscribe-page) |
| `CheckoutStatus` | Flow status string union. | [CHECKOUT.md](./CHECKOUT.md#usecheckout) |
| `CheckoutResult` | `{ status, data?, error? }`. | [CHECKOUT.md](./CHECKOUT.md#usecheckout) |
| `UseCheckoutReturn` | Return of `useCheckout`. | [CHECKOUT.md](./CHECKOUT.md#usecheckout) |
Expand Down
17 changes: 6 additions & 11 deletions src/components/SubscribeButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* SubscribeButton — subscribe the user to one of your app's plans via Hypery.
*
* Runs the full flow through {@link useCheckout}: logs the user in if needed,
* subscribes with the card they already have on Hypery, and only asks for a card
* if they have none. Like BuyButton it asks for an explicit confirmation first,
* since the card is charged off-session. Never throws.
* Runs the flow through {@link useCheckout}: logs the user in if needed, then
* opens Hypery's hosted subscribe page where the user picks the team, card and
* interval. Like BuyButton it asks for an explicit confirmation first. Never throws.
*/

"use client";
Expand All @@ -29,13 +28,9 @@ export interface SubscribeButtonProps {
label?: React.ReactNode;
/** Require a confirming second click before subscribing. Defaults to true. */
requireConfirmation?: boolean;
/** Called with the gateway response on success (`{ subscription, alreadySubscribed }`). */
/** Called on success with `{ subscription, team }` from the session result. */
onSuccess?: (data: any) => void;
/**
* Called for failures. When the card needs authentication (3-D Secure),
* `error.code === 'PAYMENT_INCOMPLETE'` and `result.data.error.clientSecret` /
* `stripeAccount` can be confirmed with Stripe.js.
*/
/** Called for failures (session creation, result fetch, `STATE_MISMATCH`). */
onError?: (error: ParsedError, result: CheckoutResult) => void;
className?: string;
branding?: BrandingConfig;
Expand All @@ -47,7 +42,7 @@ function priceText(priceCents?: number, interval?: "month" | "year"): string {
}

/**
* Subscribe the user to one of your app's plans (login and card entry handled).
* Subscribe the user to one of your app's plans (login handled; card entry happens on the hosted page).
*
* @example
* ```tsx
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type {
export { useCheckout } from './useCheckout';
export type {
CheckoutInput,
SubscribeSessionResult,
CheckoutStatus,
CheckoutResult,
UseCheckoutReturn,
Expand Down
18 changes: 11 additions & 7 deletions src/hooks/useAppSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* Loads the app's active plans (GET /api/marketplace/plans) and the user's
* subscriptions with their remaining app-scoped credit grants
* (GET /api/marketplace/subscriptions), and exposes subscribe / cancel / resume.
* `subscribe` runs the full useCheckout chain (log in → subscribe → add a card
* only if the user has none on Hypery → retry).
* `subscribe` runs the useCheckout chain: log in if needed, then Hypery's hosted
* subscribe page (the user picks the team, card and interval there).
*
* Plans are created by the app developer in the Hypery dashboard; a plan's price
* and its credit grants are configured independently.
Expand Down Expand Up @@ -107,8 +107,8 @@ export interface UseAppSubscriptionReturn {
isLoading: boolean;
error: ParsedError | null;
refresh: () => Promise<void>;
/** Subscribe to a plan (login / add-card handled). Refreshes on success. */
subscribe: (planId: string, opts?: { idempotencyKey?: string; interval?: PlanInterval }) => Promise<CheckoutResult>;
/** Subscribe on Hypery's hosted page (login handled; `interval` is the preselection). Refreshes on success. */
subscribe: (planId: string, opts?: { /** @deprecated ignored */ idempotencyKey?: string; interval?: PlanInterval }) => Promise<CheckoutResult>;
/** Stop renewal at period end (issued grants stay usable until they expire). */
cancel: (subscriptionId?: string) => Promise<boolean>;
/** Undo a pending cancellation. */
Expand Down Expand Up @@ -137,7 +137,7 @@ const LIVE = new Set(['active', 'trialing', 'past_due']);
*/
export function useAppSubscription(appId: string): UseAppSubscriptionReturn {
const { authenticatedFetch, gatewayUrl, isAuthenticated, isLoading: authLoading } = useHyperyAuth();
const { checkout } = useCheckout();
const { checkout, lastResult } = useCheckout();
const [plans, setPlans] = useState<AppPlan[]>([]);
const [subscriptions, setSubscriptions] = useState<AppSubscription[]>([]);
const [isLoading, setIsLoading] = useState(true);
Expand Down Expand Up @@ -175,6 +175,11 @@ export function useAppSubscription(appId: string): UseAppSubscriptionReturn {
if (!authLoading) void refresh();
}, [authLoading, refresh]);

// A subscription completed via a redirect return: reload.
useEffect(() => {
if (lastResult?.status === 'success' && lastResult.data?.subscription) void refresh();
}, [lastResult, refresh]);

const activeSubscription = useMemo(
() => subscriptions.find((s) => LIVE.has(s.status)) ?? null,
[subscriptions],
Expand All @@ -185,11 +190,10 @@ export function useAppSubscription(appId: string): UseAppSubscriptionReturn {
);

const subscribe = useCallback(
async (planId: string, opts?: { idempotencyKey?: string; interval?: PlanInterval }) => {
async (planId: string, opts?: { /** @deprecated ignored */ idempotencyKey?: string; interval?: PlanInterval }) => {
const result = await checkout({
kind: 'subscription',
planId,
idempotencyKey: opts?.idempotencyKey,
...(opts?.interval ? { interval: opts.interval } : {}),
});
if (result.status === 'success') await refresh();
Expand Down
Loading