koala73/worldmonitor · error · ConvexError

OWNER_NOT_BUSINESS

OWNER_NOT_BUSINESS

Error message

OWNER_NOT_BUSINESS

What it means

Structured ConvexError thrown by inviteSeats when the signed-in owner has no covering Business subscription at the current time. Seat management is an owner privilege tied to an active Business plan; users without one cannot invite teammates.

Solutions

  1. Subscribe to (or renew) the Business plan before inviting seats
  2. If a subscription exists, verify its status is covering and not expired/cancelled
  3. Refresh the client after payment completes so entitlements are re-evaluated
Defensive patterns

Strategy: validation

When it happens

Trigger: Thrown at convex/payments/businessSeats.ts:132 when the library encounters an invalid state.

Common situations: See trigger scenarios.


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

Appendix: source

Thrown at convex/payments/businessSeats.ts:131

/**
 * Owner invites up to 4 teammates at any corporate email domain. 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 7d06c8633d)