/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.
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,DOMAINfromiblai.env, defaultiblai.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 for the exact fetch routine.
Prerequisites
- Auth must be set up first (
/iblai-vibe-auth) — reuse the same token wiring. .mcp.jsonconfigured with@iblai/mcp(and skills installed vianpx skills add iblai/vibe --all).iblai.envpopulated withPLATFORM,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 fromcurrentTenant(SDK identifier; that's the Platform record) and only renders the Purchases tab when the flag is on. Theshow_paywallflag governs paywall enforcement on items — it is NOT what gates the Purchases tab. Useenable_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
IsEdxAuthenticatedand filter byrequest.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 for the full payloads and the response-branching contract.
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.ItemSubscriptionCancelViewis hard-locked torequest.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:
// 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:
- Is the user authenticated and on
/iblai-vibe-profile? - Is
enable_monetizationactuallytrueon the current Platform? Check the platform list endpoint orlocalStoragecache. Do NOT confuse it withshow_paywall— that flag gates paywall enforcement on items and has no effect on this tab. PurchasesTabis a private component owned by theProfileshell — it's NOT re-exported by@iblai/iblai-js/web-containers. Integrate via<Profile>(which rendersPurchasesTabwhencurrentTenant.enable_monetization === true); standalone use requires copying the component frompackages/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.
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:
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:
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:
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): fullItemSubscriptionSerializerresponse — 21 fields — withstatus='canceled'andcanceled_atpopulated. - Portal handoff (recurring with a
stripe_customer_id): single-field{ portal_url }viaPortalUrlResponseSerializer. The actual Stripe cancellation happens inside the Stripe Customer Portal. - 400 if recurring but
stripe_customer_idis 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 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 thecanceledstatus filter (or drops off the current page).itemSubscription— the detail view picks up the newstatusandcanceled_attimestamp.accessCheck— any gated content (useCheckAccessQueryon a paywalled page, thePaywallModaltrigger 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:
- Open the Profile page on a Platform with
enable_monetization === trueand confirm the Purchases tab is in the tab strip. - 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.
- Type into the search box; confirm the debounce kicks in after 300ms
and the list filters by the
searchparam (matches againstitem_id). - Open a recurring subscription detail, type
cancelinto the confirmation input, click the destructive button, and confirm a Stripe Customer Portal card renders with an "Open Stripe Portal" CTA. - Open a one-time or grandfathered subscription, repeat the cancel flow, and confirm the "Subscription Canceled" card renders instead.
- After a cancel, navigate to a paywalled item the user previously had
access to and confirm it is now locked (the
accessCheckinvalidation worked). - Use the pagination Previous / Next controls and confirm
pageresets 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
accessCheckin 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 invalidateaccessCheck(andsubscribers) alongside the two subscription tags. - Gating the Purchases tab on
show_paywall. Wrong flag.show_paywallcontrols paywall enforcement on items. The Purchases tab is gated onenable_monetization. They are independent. - Building a custom
PurchasesTabfrom 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.amountdirectly. It is a string.parseFloatfirst, or pass it untouched toIntl.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 SDKPurchasesTabcomponent (search, filters, list, detail, cancel branching, pagination).packages/web-containers/src/components/profile/index.tsx— the Profile shell that gatesPurchasesoncurrentTenant?.enable_monetizationand renders<PurchasesTab org={tenant} username={username} />.packages/data-layer/src/features/monetization/custom-api-slice.ts—getMySubscriptions,getItemSubscription,cancelSubscriptionendpoints and theirprovidesTags/invalidatesTagswiring.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.pyItemSubscriptionViewat line 1853 — GET detail.ItemSubscriptionCancelViewat line 1885 — POST cancel, server-side branching between immediate cancel and Stripe Customer Portal.UserAllSubscriptionsViewat line 2196 — GET paginated list.
web/ibl-dm-core-apps/ibl-dm-billing-app/billing/serializers.pyItemSubscriptionListSerializer,ItemSubscriptionSerializer,SubscriptionCancelSerializer,PortalUrlResponseSerializer.
Related skills
- /iblai-vibe-monetization — family index, auth, schema validation, Platform flags, RBAC, the full monetization API slice.
- /iblai-vibe-monetization-checkout —
how the user got here: PaywallModal,
useCheckAccessQuery(402-as-success), checkout session creation, guest checkout, public pricing. - /iblai-vibe-monetization-analytics — the Platform admin's view of the same data: revenue, subscribers list, paywalls list, admin cancel tool.
- /iblai-vibe-monetization-configure — the Platform admin's MonetizationTab inside Account: paywall config, pricing CRUD, Item Details → Paywall → Pricing wizard.
- /iblai-vibe-monetization-onboard — Stripe Connect Express onboarding for the Platform seller.
- /iblai-vibe-profile — the Profile shell that hosts this tab.
- /iblai-vibe-auth — token wiring this tab depends on.
- BRAND.md — colors, typography, spacing, component styles for any custom subscription surface you build alongside the SDK tab.