# Iblai Vibe Monetization Subscription

> Build the user-facing PurchasesTab inside the Profile page — list, detail, and cancel for the current user's subscriptions on a Platform. Use when the user mentions purchases, my subscriptions, subscription list, cancel subscription, stripe portal, grandfathered, legacy subscription, subscription detail, or cancel-at-period-end. See /iblai-vibe-monetization for the family index + auth + RBAC, /iblai-vibe-monetization-checkout for how the user got here (paywall, checkout, callback), /iblai-vibe-monetization-analytics for the admin subscriber view, /iblai-vibe-profile for the Profile shell that hosts this tab, and /iblai-vibe-auth for the token wiring.

- Skill: `iblai/iblai-vibe-monetization-subscription` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add iblai/iblai-vibe-monetization-subscription`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iblai/iblai-vibe-monetization-subscription/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: iblai (https://skillmd.com/u/iblai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iblai/iblai-vibe-monetization-subscription

---


# /iblai-vibe-monetization-subscription

Wire the post-purchase surface inside the Profile page: a paginated, searchable
list of the current user's subscriptions, a detail view per subscription, and
a cancel flow that branches between an immediate cancel and a Stripe Customer
Portal redirect. The SDK ships `PurchasesTab` — Profile renders it
automatically when the Platform has monetization enabled — so most of the work
is wiring, not UI.

> **Common setup (brand, conventions, env files, verification):** see [docs/skill-setup.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/docs/skill-setup.md).

> **Verify the API before you call it.** Fetch the live OpenAPI schema at `{dm_url}/api/docs/schema/` (browsable at `{dm_url}/api/docs/`; `{dm_url}` = `https://api.$DOMAIN/dm`, `DOMAIN` from `iblai.env`, default `iblai.app`) and confirm the URL path, method, request body, and response shape for every endpoint you reach for. The schema is the source of truth; the URLs in this skill exist for orientation and may drift between releases. See [/iblai-vibe-monetization → references/schema-validation.md](../../billing/iblai-vibe-monetization/references/schema-validation.md) for the exact fetch routine.

## Prerequisites

- Auth must be set up first (`/iblai-vibe-auth`) — reuse the same token wiring.
- `.mcp.json` configured with `@iblai/mcp` (and skills installed via
  `npx skills add iblai/vibe --all`).
- `iblai.env` populated with `PLATFORM`, `DOMAIN`, `TOKEN`. If missing:
  `curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env`
- The Profile page is mounted (`/iblai-vibe-profile`). PurchasesTab is rendered by
  the Profile shell, not by your app code directly.
- The Platform has `enable_monetization === true`. The Profile shell reads
  this flag from `currentTenant` (SDK identifier; that's the Platform record)
  and only renders the Purchases tab when the flag is on. The `show_paywall`
  flag governs paywall enforcement on items — it is NOT what gates the
  Purchases tab. Use `enable_monetization`.
- The caller is authenticated. There is no role restriction: any logged-in
  user can see their own subscriptions. The list view, detail view, and
  cancel view all use `IsEdxAuthenticated` and filter by `request.user`.

## What you'll build

Most of this is already implemented inside the SDK `PurchasesTab`. The
spec below documents what the user will see so you can recognize when
something is mis-wired, and so any custom subscription UI you write
mirrors the same shape.

| Surface | Content |
|---|---|
| Search bar | Debounced 300ms input; passes `search` to the list query (backend filters by `item_id__icontains`). |
| Filters | Status select (All / Active / Trialing / Canceled / Past Due) + item-type select (All / Agent / Course / Program / Pathway) + total count chip. |
| Subscription card | Item name, type badge, plan name + amount/interval, "Renews"/"Expires" date, "Since" date, cancel-at-period-end warning row, status badge, "Legacy" badge when grandfathered. |
| Detail view | Status, plan, type, renewal/expiry date, since date, grandfathered badge, "Type `cancel` to confirm" input + destructive cancel button. |
| Cancel result | Either a "Subscription Canceled" success card OR an "Open Stripe Portal" card — depends on the response branch. |
| Pagination | 8 items per page; Previous / Page N / Next controls; disabled when there's no next/previous page. |

## Step 1: Validate the API schema

Three endpoints back this surface. Confirm each one in the live OpenAPI schema
before writing any client code — the current schema has the shapes below at
fetch time, but a future release may shift fields. See
[references/cancel-flow.md](references/cancel-flow.md) for the full payloads
and the response-branching contract.

```bash
DOMAIN=$(grep -m1 '^DOMAIN=' iblai.env 2>/dev/null | cut -d= -f2-)
curl -sS "https://api.${DOMAIN:-iblai.app}/dm/api/docs/schema/" -o /tmp/iblai_schema.yaml
grep -E "my-subscriptions/|subscription/cancel/|subscription/$" /tmp/iblai_schema.yaml
```

You should see all three. Endpoints live under the **DM base**
(`{dm_url}`, e.g. `https://api.iblai.app/dm`) and require `Authorization:
Token <DM token>` — the **DM token**, not the AXD token (using the AXD
token returns `401`). Item-keyed endpoints expose both a canonical
(`unique_id`-keyed) form and the legacy composite form; the SDK still
builds composite URLs.

| Capability | Canonical (recommended) | Composite (legacy) |
|---|---|---|
| Subscription read | `GET {dm_url}/api/billing/items/{item_unique_id}/subscription/` | `GET {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/subscription/` |
| Subscription cancel | `POST {dm_url}/api/billing/items/{item_unique_id}/subscription/cancel/` | `POST {dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/subscription/cancel/` |
| User's full subscription list | — (platform-scoped, no canonical) | `GET {dm_url}/api/billing/platforms/{platform_key}/my-subscriptions/` |

All three require an authenticated user; the backend scopes by `request.user`
+ `platform_key`, so there is no way to read or cancel another user's
subscription. `ItemSubscriptionCancelView` is hard-locked to `request.user`
— operators cannot cancel on behalf of another user through this API.

## Step 2: Wire the Profile integration

The Profile page shell owns this tab. Confirm `/iblai-vibe-profile` has been set
up — if it has not, do that skill first and come back.

When the Profile shell mounts it reads the current Platform record. The SDK
exposes that record under the literal identifier `currentTenant` (legacy name
— treat the field as "the active Platform"). If
`currentTenant?.enable_monetization` is truthy, the shell appends a
`Purchases` tab to the tab strip and renders `PurchasesTab` when it is
active:

```ts
// inside the SDK Profile component
const isPurchasesEnabled = currentTenant?.enable_monetization;
const tabs = [
  // …other tabs
  ...(isPurchasesEnabled
    ? [{ id: 'purchases', label: 'Purchases', renderIcon: renderLucideIcon(ShoppingBag) }]
    : []),
];

{activeTab === 'purchases' && <PurchasesTab org={tenant} username={username} />}
```

You do not need to mount `PurchasesTab` yourself. If the tab is missing,
debug in this order:

1. Is the user authenticated and on `/iblai-vibe-profile`?
2. Is `enable_monetization` actually `true` on the current Platform? Check
   the platform list endpoint or `localStorage` cache. Do NOT confuse it
   with `show_paywall` — that flag gates paywall enforcement on items and
   has no effect on this tab.
3. `PurchasesTab` is a private component owned by the `Profile` shell —
   it's NOT re-exported by `@iblai/iblai-js/web-containers`. Integrate via
   `<Profile>` (which renders `PurchasesTab` when
   `currentTenant.enable_monetization === true`); standalone use requires
   copying the component from
   `packages/web-containers/src/components/profile/purchases/purchases-tab.tsx`.

## Step 3: Customize the list query

This step is only relevant if you are building a **custom** subscriptions
surface (a billing-only page, a widget on the dashboard, an admin overlay).
If you are using the SDK `PurchasesTab`, skip ahead to Step 6 to verify
cache invalidation.

```ts
import { useGetMySubscriptionsQuery, type SubscriptionObject } from '@iblai/iblai-js/data-layer';

const { data, isLoading, isFetching } = useGetMySubscriptionsQuery(
  {
    platform_key,
    page,
    page_size: 8,
    ...(statusFilter !== 'all' ? { status: statusFilter } : {}),
    ...(typeFilter !== 'all' ? { item_type: typeFilter } : {}),
    ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
  },
  { refetchOnMountOrArgChange: true },
);
```

Note: the SDK's `MySubscriptionsParams` type lists `item_name?` but the
backend only honors `search` (filters by `item_id__icontains`). Use
`search` for text matching — `item_name` is dropped silently by the
`SubscriptionListQPSerializer`.

The response envelope is the standard Platform pagination shape:

```ts
type MySubscriptionsResponse = {
  count: number;
  next_page: number | null;
  previous_page: number | null;
  results: SubscriptionObject[];
};
```

Note: the runtime paginator (`StandardPageNumberPagination` at
`core/utils/pagination.py`) returns `next_page`/`previous_page` as
integers (or `null`). The OpenAPI schema documents `next`/`previous` as
URI strings (DRF default) — that's schema drift. Trust the runtime
shape shown above.

`SubscriptionObject.price.amount` is a **string** (DRF decimal). Use the
SDK pattern `parseFloat(sub.price.amount)` or pass the string straight into
`Intl.NumberFormat(...).format()` — do not multiply / add the raw string.

Debounce the search input 300ms before forwarding it to the query, and
reset `page` to `1` whenever any filter or the debounced search changes.
Otherwise the user lands on an empty page-3 when the result set shrinks.

## Step 4: Detail view + cancel flow

Each card opens a detail view. Refetch the canonical record (the list payload
is summary-only on some Platforms) and wire the cancel mutation:

```ts
import {
  useGetItemSubscriptionQuery,
  useCancelSubscriptionMutation,
} from '@iblai/iblai-js/data-layer';

const { data: fetched, isLoading } = useGetItemSubscriptionQuery(
  { platform_key, item_type: sub.item_type, item_id: sub.item_id },
  { refetchOnMountOrArgChange: true },
);

const [cancelSubscription, { isLoading: isCanceling }] = useCancelSubscriptionMutation();
```

`useCancelSubscriptionMutation` returns ONE of two payload shapes — branch on
which key is present:

```ts
const result = await cancelSubscription({
  platform_key,
  item_type: sub.item_type,
  item_id: sub.item_id,
  ...(isRecurring ? { return_url: window.location.href } : {}),
}).unwrap();

if (result.portal_url) {
  // Recurring sub with a Stripe customer — open the Stripe Customer Portal.
  window.open(result.portal_url, '_blank');
} else if (result.status === 'canceled') {
  // One-time, grandfathered, or no-Stripe-customer — already canceled.
  // `result` is the FULL ItemSubscriptionSerializer record (21 fields)
  // with the new `status='canceled'` and `canceled_at` set.
  toast.success('Subscription canceled');
}
```

The three response branches the backend may return:

- **Immediate cancel** (one-time, grandfathered, or no
  `stripe_subscription_id`): full `ItemSubscriptionSerializer` response —
  21 fields — with `status='canceled'` and `canceled_at` populated.
- **Portal handoff** (recurring with a `stripe_customer_id`): single-field
  `{ portal_url }` via `PortalUrlResponseSerializer`. The actual Stripe
  cancellation happens inside the Stripe Customer Portal.
- **400** if recurring but `stripe_customer_id` is missing on the user.
- **404** if no subscription exists for the caller on this item.
- **500** if Stripe Customer Portal creation throws.

The destructive button must remain disabled until the user types the literal
string `cancel` into a confirmation input. This is what `PurchasesTab` does —
match it in any custom UI. Treat the cancel button as you would any other
irreversible action.

See [references/cancel-flow.md](references/cancel-flow.md) for the full
response shapes, the server-side branching logic, and the optional
`return_url` body field.

## Step 5: Grandfathered subscriptions

Some users are granted legacy access — historical purchases, comped seats,
admin-issued lifetime passes. The backend flags these with
`is_grandfathered: true` on `SubscriptionObject`. PurchasesTab renders a
small amber "Legacy" / "Grandfathered" badge on both the card and the
detail view.

Grandfathered subscriptions have NO Stripe customer. When the user cancels,
the backend takes the immediate-cancel branch and returns the FULL
`ItemSubscriptionSerializer` record (21 fields) with the new
`status='canceled'` and `canceled_at` populated — there is no portal URL
because there is nothing for Stripe to manage. Render the
"Subscription Canceled" success card and refresh the list.

In any custom UI, treat `is_grandfathered` as decorative — do not change
the cancel button copy. The cancel mutation handles the routing for you.

## Step 6: Cache invalidation

`useCancelSubscriptionMutation` invalidates four RTK Query tags:

- `mySubscriptions` — the Purchases list refetches and the cancelled row
  flips to the `canceled` status filter (or drops off the current page).
- `itemSubscription` — the detail view picks up the new `status` and
  `canceled_at` timestamp.
- `accessCheck` — any gated content (`useCheckAccessQuery` on a paywalled
  page, the `PaywallModal` trigger on a locked agent) flips back to
  locked. Forget this and the rest of the app shows a stale "you have
  access" UI.
- `subscribers` — the admin subscriber list (rendered by
  `/iblai-vibe-monetization-analytics`) reflects the churn immediately.

If you are building a custom cancel surface that calls a thin wrapper
around the endpoint instead of the SDK mutation, replicate this exact tag
set. Dropping `accessCheck` is the most common bug.

## Verify

After wiring:

1. Open the Profile page on a Platform with `enable_monetization === true`
   and confirm the Purchases tab is in the tab strip.
2. Log in as a user with at least one subscription and verify the list
   renders 8 cards per page with the correct status / type / Legacy badges.
3. Type into the search box; confirm the debounce kicks in after 300ms
   and the list filters by the `search` param (matches against `item_id`).
4. Open a recurring subscription detail, type `cancel` into the
   confirmation input, click the destructive button, and confirm a
   Stripe Customer Portal card renders with an "Open Stripe Portal" CTA.
5. Open a one-time or grandfathered subscription, repeat the cancel flow,
   and confirm the "Subscription Canceled" card renders instead.
6. After a cancel, navigate to a paywalled item the user previously had
   access to and confirm it is now locked (the `accessCheck` invalidation
   worked).
7. Use the pagination Previous / Next controls and confirm `page` resets
   to 1 when any filter or the debounced search changes.

## Common mistakes

- **Assuming the cancel mutation always returns `portal_url`.** It does
  not. One-time purchases, grandfathered subscriptions, and any
  subscription without a Stripe customer return `{ status: 'canceled' }`
  immediately. Branch on which key is present, not on subscription type.
- **Forgetting to invalidate `accessCheck` in a custom cancel wrapper.**
  The cancel succeeds, the Purchases list refreshes, but every gated page
  in the app keeps showing "you have access" until a hard reload. Always
  invalidate `accessCheck` (and `subscribers`) alongside the two
  subscription tags.
- **Gating the Purchases tab on `show_paywall`.** Wrong flag.
  `show_paywall` controls paywall enforcement on items. The Purchases tab
  is gated on `enable_monetization`. They are independent.
- **Building a custom `PurchasesTab` from scratch.** The Profile shell owns
  the tab; the SDK exports the component; both ship a tested UI for
  search, filters, detail, cancel, and pagination. Add a custom widget
  elsewhere if you must, but do not replace the one Profile renders.
- **Doing arithmetic on `price.amount` directly.** It is a string.
  `parseFloat` first, or pass it untouched to `Intl.NumberFormat`. Don't
  add `"19.00"` to `"5.00"` and ship `"19.005.00"` in production.
- **Hardcoding URL paths.** The SDK constants live in
  `MONETIZATION_ENDPOINTS` (`packages/data-layer/src/features/monetization/`).
  When refactoring, import from there — do not retype the path.

## MCP tools for further detail

With `@iblai/mcp` in `.mcp.json`, query the MCP tools for richer help:

- `get_hook_info("useGetMySubscriptionsQuery")`
- `get_hook_info("useGetItemSubscriptionQuery")`
- `get_hook_info("useCancelSubscriptionMutation")`
- `get_component_info("Profile")`

## Files in this skill's scope

Frontend (`iblai/ibl-web-frontend`):

- `packages/web-containers/src/components/profile/purchases/purchases-tab.tsx`
  — the SDK `PurchasesTab` component (search, filters, list, detail,
  cancel branching, pagination).
- `packages/web-containers/src/components/profile/index.tsx` — the Profile
  shell that gates `Purchases` on `currentTenant?.enable_monetization`
  and renders `<PurchasesTab org={tenant} username={username} />`.
- `packages/data-layer/src/features/monetization/custom-api-slice.ts`
  — `getMySubscriptions`, `getItemSubscription`, `cancelSubscription`
  endpoints and their `providesTags` / `invalidatesTags` wiring.
- `packages/data-layer/src/features/monetization/types.ts` —
  `SubscriptionObject`, `SubscriptionPrice`, `MySubscriptionsParams`,
  `MySubscriptionsResponse`, `CancelSubscriptionArgs`,
  `CancelSubscriptionResponse`.

Backend (`iblai/ibl-dm-pro`):

- `web/ibl-dm-core-apps/ibl-dm-billing-app/billing/views.py`
  - `ItemSubscriptionView` at line 1853 — GET detail.
  - `ItemSubscriptionCancelView` at line 1885 — POST cancel, server-side
    branching between immediate cancel and Stripe Customer Portal.
  - `UserAllSubscriptionsView` at line 2196 — GET paginated list.
- `web/ibl-dm-core-apps/ibl-dm-billing-app/billing/serializers.py`
  - `ItemSubscriptionListSerializer`, `ItemSubscriptionSerializer`,
    `SubscriptionCancelSerializer`, `PortalUrlResponseSerializer`.

## Related skills

- [/iblai-vibe-monetization](../../billing/iblai-vibe-monetization/SKILL.md) — family index, auth,
  schema validation, Platform flags, RBAC, the full monetization API slice.
- [/iblai-vibe-monetization-checkout](../../billing/iblai-vibe-monetization-checkout/SKILL.md) —
  how the user got here: PaywallModal, `useCheckAccessQuery` (402-as-success),
  checkout session creation, guest checkout, public pricing.
- [/iblai-vibe-monetization-analytics](../../billing/iblai-vibe-monetization-analytics/SKILL.md) —
  the Platform admin's view of the same data: revenue, subscribers list,
  paywalls list, admin cancel tool.
- [/iblai-vibe-monetization-configure](../../billing/iblai-vibe-monetization-configure/SKILL.md) —
  the Platform admin's MonetizationTab inside Account: paywall config,
  pricing CRUD, Item Details → Paywall → Pricing wizard.
- [/iblai-vibe-monetization-onboard](../../billing/iblai-vibe-monetization-onboard/SKILL.md) —
  Stripe Connect Express onboarding for the Platform seller.
- [/iblai-vibe-profile](../../users/iblai-vibe-profile/SKILL.md) — the Profile shell that
  hosts this tab.
- [/iblai-vibe-auth](../../start/iblai-vibe-auth/SKILL.md) — token wiring this tab depends on.
- [BRAND.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/BRAND.md)
  — colors, typography, spacing, component styles for any custom subscription
  surface you build alongside the SDK tab.

