SuperAPI AI Directory

iblai-vibe-monetization-subscription

Office & productivity

View repository

Updated 10-15 22:16

About this skill

/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.

Profile › Purchases — search, status and type filters, empty state

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, 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 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 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. 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:

// 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.

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): 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 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