{"record":{"id":"a5ffa123b08986e0","repo":"koala73/worldmonitor","slug":"owner-not-business","errorCode":"OWNER_NOT_BUSINESS","errorMessage":"OWNER_NOT_BUSINESS","messagePattern":"OWNER_NOT_BUSINESS","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/businessSeats.ts","lineNumber":132,"sourceCode":"\n/**\n * Owner invites up to 4 same-domain teammates. Pending invites count against\n * the cap; each gets a single-use HMAC token emailed via Resend.\n */\nexport const inviteSeats = mutation({\n  args: { emails: v.array(v.string()) },\n  handler: async (ctx, args) => {\n    const userId = await requireUserId(ctx);\n    const identity = await resolveUserIdentity(ctx);\n    const ownerEmail = identity?.email?.trim();\n    if (!ownerEmail) {\n      throw new ConvexError({ kind: \"OWNER_EMAIL_UNAVAILABLE\" });\n    }\n\n    const now = Date.now();\n    const businessSub = await getCoveringBusinessSubscription(ctx, userId, now);\n    if (!businessSub) {\n      throw new ConvexError({ kind: \"OWNER_NOT_BUSINESS\" });\n    }\n\n    const ownerDomain = extractDomain(ownerEmail);\n    if (!ownerDomain || !isCorporateDomain(ownerEmail)) {\n      throw new ConvexError({ kind: \"OWNER_DOMAIN_NOT_CORPORATE\" });\n    }\n\n    // Serialize concurrent inviteSeats / removeSeat calls for this Business\n    // subscription so the cap check below cannot be bypassed by a race.\n    await touchBusinessSeatLock(ctx, businessSub.dodoSubscriptionId, now);\n\n    const normalizedOwnerEmail = ownerEmail.toLowerCase();\n    // Dedupe: the existingByEmail check below only guards against emails\n    // that already had a grant BEFORE this call — a duplicate within the\n    // SAME args.emails array would otherwise slip past it (neither\n    // occurrence is in existingGrants yet) and create two grant rows for\n    // one invitee.\n    const emails = Array.from(","sourceCodeStart":114,"sourceCodeEnd":150,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/payments/businessSeats.ts#L114-L150","documentation":"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\" }`).","triggerScenarios":"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.","commonSituations":"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.","solutions":["Upgrade to (or reactivate) a Business subscription before inviting seats.","Gate the invite UI on an active Business entitlement check so the option is hidden for ineligible users.","If the subscription should be active, verify the entitlement recompute picked up the latest Business sub."],"exampleFix":"// before\nawait inviteSeats({ emails: [...] });\n\n// after\nconst entitlement = await getMyEntitlement();\nif (entitlement.plan !== \"business\") { routeToUpgrade(); return; }\nawait inviteSeats({ emails: [...] });","handlingStrategy":"validation","validationCode":"const entitlement = await getMyEntitlement();\nif (entitlement.plan !== \"business\" || !entitlement.active) {\n  routeToUpgrade();\n  return;\n}\nawait inviteSeats({ emails });","typeGuard":"function isBusinessEntitlement(e: { plan: string; active: boolean }): boolean {\n  return e.plan === \"business\" && e.active;\n}","tryCatchPattern":null,"preventionTips":["Gate the seat-invite UI on an active Business entitlement.","Re-check entitlement after a subscription change before allowing invites."],"tags":["billing","convex","business-seats","entitlement","validation"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}