# /iblai-vibe-monetization-analytics

> Mirrored from [`iblai/vibe`](https://github.com/iblai/vibe) · [`skills/billing/iblai-vibe-monetization-analytics/SKILL.md`](https://github.com/iblai/vibe/blob/main/skills/billing/iblai-vibe-monetization-analytics/SKILL.md). This page is generated — edit it in the repository, not here.

Build custom Platform admin surfaces on top of the four monetization
analytics endpoints — revenue, all-Platform subscribers, per-item
subscribers, paywalls list — plus the `CancelSubscription` self-service
helper that cancels the **caller's own** subscription on a given
`(item_type, item_id)`.

> **A true admin override (cancel-on-behalf-of-user) does not exist.**
> The cancel endpoint is hard-locked to `request.user`; there is no
> `user_id` parameter. To force-cancel another user's subscription, an
> operator must do so directly in the Stripe Dashboard.

> **Important:** `MonetizationTab` (see `/iblai-vibe-monetization-configure`)
> does NOT render revenue or subscriber data. The SDK ships the
> data-layer hooks and one self-service component, but no out-of-the-box
> analytics UI — this skill is the playbook for assembling that yourself.

> **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/` (`{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. See [/iblai-vibe-monetization → references/schema-validation.md](https://github.com/iblai/vibe/blob/main/skills/billing/iblai-vibe-monetization/references/schema-validation.md).

## Prerequisites

- Auth must be set up first (`/iblai-vibe-auth`) — reuse the same
  `Authorization: Token <token>` wiring the rest of the app uses.
- `.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`
- **Platform admin role required.** All four analytics endpoints
  (paywalls, Platform-wide subscribers, per-item subscribers, revenue)
  are gated by `IsPlatformAdmin`. Non-admin → `403`; anonymous → `401`.
  See `/iblai-vibe-rbac`.
- **`enable_monetization === true` required.** All four analytics views
  also run the `_enforce_can_sell_items` mixin check (returns `403` when
  the flag is off) — this applies equally to the per-item subscribers
  endpoint, not just the Platform-wide ones. The flag ships in the SDK's
  `tenants` array.
  See [/iblai-vibe-monetization → references/platform-flags.md](https://github.com/iblai/vibe/blob/main/skills/billing/iblai-vibe-monetization/references/platform-flags.md).
- **Stripe Connect onboarded** with `is_ready_for_payments === true`.
  Without it, revenue is empty and subscribers contains only free /
  grandfathered rows. Wire `/iblai-vibe-monetization-onboard` first.

## What you'll build

1. **Revenue card** — `useGetRevenueQuery` → `sales_volume`,
   `sales_count`, `currency`. Optionally pair with
   `useGetStripeConnectStatusQuery` for commission percentages.
2. **Subscribers list (Platform-wide)** — paginated table via
   `useListSubscribersQuery` with status + `item_type` filters and
   server-side search.
3. **Per-item subscribers drilldown** — `useListItemSubscribersQuery`,
   typically reached from a paywall detail page or leaderboard row.
4. **Paywalls overview** — `useListPaywallsQuery` with `item_type` +
   `is_enabled` filters; useful for leaderboards, audits, or custom
   surfaces beyond the bundled `MonetizationTab`.
5. **Self-service `CancelSubscription` helper** — cancels the **caller's
   own** subscription on a given `(item_type, item_id)`. NOT an admin
   override; the endpoint resolves the subscription via `request.user`.
   To cancel another user's subscription, use the Stripe Dashboard.

## Step 1: Validate the API schema

Confirm the four analytics endpoints + the cancel endpoint are present
before writing any code:

```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 "/api/billing/platforms/\{platform_key\}/(revenue|subscribers|paywalls)/|/api/billing/platforms/\{platform_key\}/items/\{item_type\}/\{item_id\}/(subscribers|subscription/cancel)/" /tmp/iblai_schema.yaml
```

You should see five paths. The full per-endpoint catalog (method, query
params, response shape, source view, permission class) lives in
[`references/analytics-api.md`](https://github.com/iblai/vibe/blob/main/skills/billing/iblai-vibe-monetization-analytics/references/analytics-api.md).

All analytics 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. The platform-scoped endpoints
have **no canonical alternative** (they aggregate across items). Only
the item-scoped subscribers endpoint has a canonical (`unique_id`-keyed)
counterpart.

| Endpoint | Form | Hook |
|---|---|---|
| `{dm_url}/api/billing/platforms/{platform_key}/revenue/` | (platform-scoped, no canonical) | `useGetRevenueQuery` |
| `{dm_url}/api/billing/platforms/{platform_key}/subscribers/` | (platform-scoped, no canonical) | `useListSubscribersQuery` |
| `{dm_url}/api/billing/platforms/{platform_key}/paywalls/` | (platform-scoped, no canonical) | `useListPaywallsQuery` |
| `{dm_url}/api/billing/items/{item_unique_id}/subscribers/` | **Canonical (recommended)** | direct fetch (SDK hook is composite) |
| `{dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/subscribers/` | Composite (legacy) | `useListItemSubscribersQuery` |

Cancel reuses the subscription cancel endpoint —
canonical `{dm_url}/api/billing/items/{item_unique_id}/subscription/cancel/`
or composite `{dm_url}/api/billing/platforms/{platform_key}/items/{item_type}/{item_id}/subscription/cancel/`
— via `useCancelSubscriptionMutation` (which still builds the composite
URL). It is the same endpoint the user-side `PurchasesTab` calls and is
hard-locked to `request.user` — operators cannot cancel on behalf of
another user through this API. See `/iblai-vibe-monetization-subscription`
for the `portal_url` vs immediate `status: "canceled"` branch.

## Step 2: Revenue

`useGetRevenueQuery({ platform_key })` returns three fields:

```ts
interface RevenueResponse {
  sales_volume: string;   // DRF DecimalField serializes as string, e.g. "1499.50" — coerce with Number()
  sales_count: number;    // number of completed sales (e.g. 12)
  currency: string;       // ISO 4217 lower-cased (typically "usd")
}
```

> **`sales_volume` ships as a string** (DRF `DecimalField` serializes as
> `"1499.50"`). Coerce with `Number(data.sales_volume)` before passing
> to `Intl.NumberFormat` — formatting a string yields `NaN`.

```tsx
import { useGetRevenueQuery } from '@iblai/iblai-js/data-layer';

function RevenueCard({ platformKey }: { platformKey: string }) {
  const { data } = useGetRevenueQuery({ platform_key: platformKey });
  if (!data) return <Skeleton className="h-32 w-full" />;
  const formatted = new Intl.NumberFormat(undefined, {
    style: 'currency', currency: data.currency.toUpperCase(),
  }).format(Number(data.sales_volume));
  return (
    <Card><CardContent className="p-5">
      <p className="text-xs text-gray-500">Total revenue</p>
      <p className="text-2xl font-semibold">{formatted}</p>
      <p className="text-xs text-gray-500 mt-1">
        {data.sales_count} completed sale{data.sales_count === 1 ? '' : 's'}
      </p>
    </CardContent></Card>
  );
}
```

**Where the numbers come from.** The backend aggregates `ItemPaymentRecord`
rows written by Stripe webhook handlers — NOT a live read against Stripe.
Expect a short lag between checkout and the number ticking up. **Currency
is a single Platform field**, not a per-item breakdown; for multi-currency,
aggregate by `price.currency` from the subscribers list yourself.

## Step 3: Commission interpretation

The commission ibl.ai takes per item type is on the Stripe Connect
status endpoint, NOT on the revenue response.

```tsx
import { useGetStripeConnectStatusQuery } from '@iblai/iblai-js/data-layer';

function CommissionTable({ platformKey }: { platformKey: string }) {
  const { data } = useGetStripeConnectStatusQuery({ platform_key: platformKey });
  // Treat as Record<string, number> — three layers disagree on which keys exist (see note below)
  const commission = (data?.commission_percent ?? {}) as Record<string, number>;
  const entries = Object.entries(commission);
  if (entries.length === 0) return null;
  return (
    <ul className="text-sm space-y-1">
      {entries.map(([itemType, pct]) => (
        <li key={itemType}>
          {itemType === 'mentor' ? 'Agent' : itemType}: {pct}%
        </li>
      ))}
    </ul>
  );
}
```

`commission_percent` has a **3-way divergence** — treat it as
`Record<string, number>` and iterate `Object.entries` defensively rather
than reading hardcoded keys:

| Layer | Keys declared |
|---|---|
| Backend wire | `mentor`, `course`, `program`, `pathway` (4) |
| OpenAPI schema component `ItemTypeCommission` | `mentor`, `course`, `program` (3 — no pathway) |
| SDK TypeScript type | `mentor`, `course` (2) |

The backend reads each percentage from per-Platform `Config` keys
(`STRIPE_ITEM_COMMISSION_PERCENT_MENTOR`, `_COURSE`, `_PROGRAM`,
`_PATHWAY`), so at runtime you may see up to four keys; never assume
exactly four are present. Render `mentor` as **"Agent"** to stay aligned
with the SDK's `displayItemType` helper —
see [/iblai-vibe-monetization → references/item-types.md](https://github.com/iblai/vibe/blob/main/skills/billing/iblai-vibe-monetization/references/item-types.md).

**Display informationally only.** Commission flows back to ibl.ai
automatically via Stripe Connect destination charges; do NOT subtract
from `sales_volume` to compute a "net" number unless explicitly asked.

## Step 4: Subscribers — Platform-wide

`useListSubscribersQuery` returns a paginated list of every subscription
across every item on the Platform:

```ts
interface ListSubscribersParams {
  platform_key: string;
  status?: 'active' | 'free' | 'grandfathered' | 'trialing'
         | 'past_due' | 'canceled' | 'incomplete';
  item_type?: string;       // 'mentor' | 'course' | 'program' | 'pathway' | 'custom:foo'
  page?: number;
  page_size?: number;
}

// 12 flat fields per row — flat (NOT nested under `user`)
interface SubscriberRow {
  unique_id: string; user_id: number; username: string; email: string;
  item_type: string;  // 'mentor' | 'course' | 'program' | 'pathway' | 'custom:foo'
  item_id: string; item_name: string;
  status: 'active' | 'free' | 'grandfathered' | 'trialing'
        | 'past_due' | 'canceled' | 'incomplete';
  price: unknown;     // PaywallPrice — interval, amount, currency, is_active
  created_at: string; updated_at: string;  // ISO 8601
}

interface ListSubscribersResponse {
  count: number;
  next_page: number | null;
  previous_page: number | null;
  results: SubscriberRow[];
}
```

`user_id`, `username`, and `email` sit at the TOP level (NOT nested
under a `user` object) — admin tables render who is subscribed with no
second roundtrip per row. This is the slimmer
`ItemSubscriptionListSerializer`; the per-item endpoint returns the
richer `ItemSubscriptionSerializer` shape — see Step 5.

```tsx
function SubscribersTable({ platformKey }: { platformKey: string }) {
  const [status, setStatus] = useState<string | undefined>();
  const [page, setPage] = useState(1);
  const { data } = useListSubscribersQuery({
    platform_key: platformKey, status, page, page_size: 25,
  });
  // render data.results; paginate via data.next_page / data.previous_page
  // wrap controls in a status <Select>: active | trialing | past_due |
  // canceled | grandfathered | free | incomplete (omit → all statuses)
}
```

Pagination uses page numbers, not cursors — increment `page` when
`next_page` is non-null. Backend honors `page_size` as a query param.

## Step 5: Subscribers — per-item

When the admin drills into a specific paywall, render that item's
subscribers with `useListItemSubscribersQuery`. It accepts
`(item_type, item_id)` instead of bare query params:

```tsx
const { data } = useListItemSubscribersQuery({
  platform_key: platformKey,
  item_type: itemType,
  item_id: itemId,
  status: 'active',
});
// data.results[i] carries username, email, price, status, current_period_end
```

Response envelope is identical to the Platform-wide subscribers list
(`count`, `next_page`, `previous_page`, `results`), but **each row is
richer** — the per-item endpoint serializes through
`ItemSubscriptionSerializer` (~21 flat fields) instead of the slimmer
`ItemSubscriptionListSerializer` (12 flat fields). Extra fields you can
read here that are NOT on the Platform-wide list:
`current_period_start`, `current_period_end`, `trial_end`,
`cancel_at_period_end`, `canceled_at`, `is_grandfathered`,
`grandfathered_at`, `billing_portal_url`, `metadata`. The only filter
supported on this endpoint is `status`; if you also need filtering by
date or username, fall back to `useListSubscribersQuery` and filter
client-side.

## Step 6: Paywalls list

`useListPaywallsQuery` returns every paywall configuration on the
Platform — the same rows the bundled `MonetizationTab` renders, but
you can pull them into any custom surface (leaderboards, audit screens,
billing-ops dashboards):

```ts
interface ListPaywallsParams {
  platform_key: string;
  item_type?: string;
  is_enabled?: boolean;     // narrow to live paywalls
  page?: number;
  page_size?: number;
}

interface ListPaywallsResponse {
  count: number;
  next_page: number | null;
  previous_page: number | null;
  results: PaywallConfigResponse[];
}
```

Common patterns:

- **Active-paywalls leaderboard.** Fetch with `is_enabled: true`, sort
  by `prices[0].amount` or by a count from a follow-up
  `useListItemSubscribersQuery` per row.
- **Paywall health audit.** Fetch all paywalls and flag rows where
  `prices.length === 0` or every price has `is_active === false` — these
  are configured but un-sellable. See `/iblai-vibe-monetization-configure`.
- **Item-type breakdown.** Group `results` by `item_type` to get a
  "paywalls per category" widget. Use `displayItemType` so `mentor`
  renders as **"Agent"**.

Each `PaywallConfigResponse` carries `prices: PaywallPrice[]` inline —
no second roundtrip per row to draw the entry-level price.

## Step 7: Self-service CancelSubscription helper

The SDK ships a stand-alone `CancelSubscription` component, but it is
**not currently re-exported** from `@iblai/iblai-js/web-containers` (the
public `exports` map only declares `.`, `./next`, `./sso`, `./styles`).
To use it standalone, either wait for the SDK to add an export, or copy
`packages/web-containers/src/components/profile/monetization/cancel-subscription.tsx`
from `iblai/ibl-web-frontend` into your app.

```tsx
import CancelSubscription from '@/components/monetization/cancel-subscription';

<CancelSubscription platformKey={currentTenant.key} />
```

The component renders an inline form: pick `item_type`
(`mentor` | `course` | `program` | `pathway`), enter an `item_id`, click
**Look Up**, and the matching subscription card appears with a
confirm-typing gate. Under the hood it uses
`useLazyGetItemSubscriptionQuery` for the lookup and
`useCancelSubscriptionMutation` — the same mutation the user-side
`PurchasesTab` uses.

**This cancels the CALLER's own subscription, not someone else's.**
`ItemSubscriptionCancelView` (`billing/views.py:1885`) is hard-locked to
`request.user`; there is no `user_id` parameter and no admin override.
Spoofing `(item_type, item_id)` only changes which of the caller's own
subscriptions is resolved. To force-cancel another user's subscription,
use the Stripe Dashboard.

**Branch on the response.** Recurring (`price.interval === 'month' | 'year'`)
returns `{portal_url}` — the caller must follow that URL to finish the
cancel in Stripe's portal. Non-recurring returns the full subscription
record with `status: 'canceled'` immediately. Copy this branch verbatim:

```ts
if (result.portal_url) {
  // open Stripe portal — recurring path
} else if (result.status === 'canceled') {
  // immediate cancel — show success
}
```

## Verify

Run `/iblai-vibe-ops-test` before telling the user the work is ready:

1. `pnpm build` — must pass with zero errors.
2. Touch test `/admin/monetization` in the browser as `/iblai-vibe-ops-test` says.
3. Revenue card shows `sales_count >= 1` on a Platform with at least one
   completed paid checkout, formatted in the Platform's currency.
4. Subscribers list paginates: incrementing `page` updates `next_page` to
   the next index (or `null` on the last page).
5. Status filter narrows the list — switching to `canceled` drops
   `active` rows.
6. Paywalls list mirrors `MonetizationTab` content; switching
   `is_enabled` to `false` surfaces any disabled paywalls.
7. Commission table renders one row per key returned in
   `commission_percent`. Iterate `Object.entries` defensively — do NOT
   assert exactly four rows. Backend returns up to four, but the OpenAPI
   schema declares three (no `pathway`) and the SDK type declares two
   (`mentor`, `course`).
8. CancelSubscription lookup on one of the **caller's own**
   subscriptions returns the subscription card; confirm-cancel either
   opens a Stripe portal (recurring) or returns the full subscription
   record with `status: 'canceled'` (one-time). Lookup of an item the
   caller does not own returns `404`.

## Common mistakes

- **Revenue dashboard before webhooks land.** `useGetRevenueQuery`
  aggregates `ItemPaymentRecord` rows written by webhook handlers — a
  just-completed checkout will not appear until the
  `checkout.session.completed` webhook processes. Show a "last updated"
  timestamp or refresh-on-click.
- **Forgetting to coerce `sales_volume`.** It ships as a string
  (`"1499.50"`); pass it through `Number()` before `Intl.NumberFormat`.
- **Assuming multi-currency revenue.** `RevenueResponse.currency` is one
  string. Aggregate by `price.currency` from subscribers if you need a
  per-currency breakdown.
- **Subtracting commission from `sales_volume`.** Destination charges
  already route commission to ibl.ai; presenting `sales_volume` minus
  commission as "net" double-counts.
- **Cancel ignoring the `portal_url` branch.** Recurring subscriptions
  cannot be canceled server-side — Stripe requires the portal flow.
  Mirror the `portal_url` vs `status === 'canceled'` switch from the
  shipped component.
- **Expecting `CancelSubscription` to be an admin-override tool.** The
  cancel endpoint resolves the subscription via `request.user` and has
  no `user_id` parameter; the component takes only
  `platform_key, item_type, item_id, return_url?`. To force-cancel
  another user's subscription, an operator must use the Stripe Dashboard.
- **Calling these endpoints with a non-admin token.** Every endpoint is
  gated by `IsPlatformAdmin` + `_enforce_can_sell_items`; non-admin →
  `403`, wrong-Platform record → `404`. Catch them separately.
- **Hardcoding `tenant_key` from `localStorage`.** Read it from the SDK
  auth context; the `tenants` array gets rewritten on Platform switches.

## MCP tools for further detail

Query the data-layer MCP for hook param shapes, filters, and cache tags:
`get_hook_info(...)` for `useGetRevenueQuery`, `useListSubscribersQuery`,
`useListItemSubscribersQuery`, `useListPaywallsQuery`, and
`useGetStripeConnectStatusQuery` (the last one carries the
`commission_percent` dict and `is_ready_for_payments` gate).

## Files in this skill's scope

Frontend (read via `gh api repos/iblai/ibl-web-frontend/contents/<path>`):

- `packages/data-layer/src/features/monetization/{custom-api-slice,types,constants}.ts` —
  query defs, response/param types, and endpoint paths.
- `packages/web-containers/src/components/profile/monetization/cancel-subscription.tsx` —
  the shipped `CancelSubscription` component (not currently re-exported
  from `@iblai/iblai-js/web-containers`; copy locally to use).

Backend (in `ibl-dm-pro/web/ibl-dm-core-apps/ibl-dm-billing-app/billing/`):

- `views.py:2156` — `PlatformRevenueView` (`IsPlatformAdmin`).
- `views.py:2073` — `PlatformSubscribersView` (`IsPlatformAdmin`,
  paginated, `status` + `item_type` filters).
- `views.py:2030` — `ItemSubscribersView` (`IsPlatformAdmin`,
  paginated, `status` filter).
- `views.py:2115` — `PlatformPaywallsView` (`IsPlatformAdmin`,
  paginated, `item_type` + `is_enabled` filters).
- `views.py:1885` — `ItemSubscriptionCancelView` (`IsEdxAuthenticated`,
  hard-locked to `request.user`; no admin override, no `user_id` param).
- `dl_iblai_services_app/services/stripe/item_paywall_handlers.py` —
  `handle_item_checkout_completed`, the Stripe webhook handler that
  writes the `ItemPaymentRecord` rows the revenue endpoint aggregates.
  (`views.py:2333` is the post-redirect `ItemCheckoutCallbackView`, not
  the webhook entrypoint; both call `handle_item_checkout_completed`.)
- `dl_iblai_services_app/models/stripe_connect.py:120` —
  `commission_percent` property reading the four
  `STRIPE_ITEM_COMMISSION_PERCENT_{MENTOR,COURSE,PROGRAM,PATHWAY}` keys.

Full per-endpoint catalog (params, response shape, error modes):
[`references/analytics-api.md`](https://github.com/iblai/vibe/blob/main/skills/billing/iblai-vibe-monetization-analytics/references/analytics-api.md).

## Related skills

- `/iblai-vibe-monetization` — Family index, schema-validation, Platform flags.
- `/iblai-vibe-monetization-onboard` — Stripe Connect; `commission_percent` +
  `is_ready_for_payments` live on Connect status.
- `/iblai-vibe-monetization-configure` — Paywall create/edit; the rows
  `useListPaywallsQuery` returns are the rows `MonetizationTab` edits.
- `/iblai-vibe-monetization-subscription` — User-side counterpart; details
  the `portal_url` branch of `CancelSubscription`.
- `/iblai-vibe-auth` — Token wiring. `/iblai-vibe-rbac` — `IsPlatformAdmin` gate.
- **Brand**: [BRAND.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/BRAND.md)
