koala73/worldmonitor · error · ConvexError

OWNER_NOT_BUSINESS

OWNER_NOT_BUSINESS

Error message

OWNER_NOT_BUSINESS

What it means

Thrown by `inviteSeats` when `getCoveringBusinessSubscription` returns null — the caller has no active Business-tier subscription at the current time, so they are not authorized to invite teammates. Object-typed ConvexError (`{ kind: "OWNER_NOT_BUSINESS" }`).

Source

Thrown at convex/payments/businessSeats.ts:132

/**
 * Owner invites up to 4 same-domain teammates. Pending invites count against
 * the cap; each gets a single-use HMAC token emailed via Resend.
 */
export const inviteSeats = mutation({
  args: { emails: v.array(v.string()) },
  handler: async (ctx, args) => {
    const userId = await requireUserId(ctx);
    const identity = await resolveUserIdentity(ctx);
    const ownerEmail = identity?.email?.trim();
    if (!ownerEmail) {
      throw new ConvexError({ kind: "OWNER_EMAIL_UNAVAILABLE" });
    }

    const now = Date.now();
    const businessSub = await getCoveringBusinessSubscription(ctx, userId, now);
    if (!businessSub) {
      throw new ConvexError({ kind: "OWNER_NOT_BUSINESS" });
    }

    const ownerDomain = extractDomain(ownerEmail);
    if (!ownerDomain || !isCorporateDomain(ownerEmail)) {
      throw new ConvexError({ kind: "OWNER_DOMAIN_NOT_CORPORATE" });
    }

    // Serialize concurrent inviteSeats / removeSeat calls for this Business
    // subscription so the cap check below cannot be bypassed by a race.
    await touchBusinessSeatLock(ctx, businessSub.dodoSubscriptionId, now);

    const normalizedOwnerEmail = ownerEmail.toLowerCase();
    // Dedupe: the existingByEmail check below only guards against emails
    // that already had a grant BEFORE this call — a duplicate within the
    // SAME args.emails array would otherwise slip past it (neither
    // occurrence is in existingGrants yet) and create two grant rows for
    // one invitee.
    const emails = Array.from(

View on GitHub (pinned to ffec79ac33)

Solutions

  1. Upgrade to (or reactivate) a Business subscription before inviting seats.
  2. Gate the invite UI on an active Business entitlement check so the option is hidden for ineligible users.
  3. If the subscription should be active, verify the entitlement recompute picked up the latest Business sub.

Example fix

// before
await inviteSeats({ emails: [...] });

// after
const entitlement = await getMyEntitlement();
if (entitlement.plan !== "business") { routeToUpgrade(); return; }
await inviteSeats({ emails: [...] });
Defensive patterns

Strategy: validation

Validate before calling

const entitlement = await getMyEntitlement();
if (entitlement.plan !== "business" || !entitlement.active) {
  routeToUpgrade();
  return;
}
await inviteSeats({ emails });

Type guard

function isBusinessEntitlement(e: { plan: string; active: boolean }): boolean {
  return e.plan === "business" && e.active;
}

Prevention

When it happens

Trigger: A Pro (non-Business) or free user opens the seat-invite UI and calls `inviteSeats`. Also fires for a former Business subscriber whose subscription expired or was cancelled.

Common situations: User downgraded from Business to Pro; Business subscription lapsed/expired; the UI failed to gate the invite panel behind an active Business entitlement; grace period ended.

Related errors


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/a5ffa123b08986e0. Report an issue: GitHub.