{"record":{"id":"e9dbf4078ecf6fb3","repo":"koala73/worldmonitor","slug":"seat-cap-reached","errorCode":"SEAT_CAP_REACHED","errorMessage":"SEAT_CAP_REACHED","messagePattern":"SEAT_CAP_REACHED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/businessSeats.ts","lineNumber":211,"sourceCode":"        if (existing.status === \"accepted\") {\n          results.push({ email, grantId: existing._id, status: \"already_accepted\" });\n          continue;\n        }\n        if (existing.status === \"pending\" && existing.expiresAt > now) {\n          results.push({ email, grantId: existing._id, status: \"already_pending\" });\n          continue;\n        }\n      }\n      newEmails.push(email);\n    }\n\n    const currentCount = await countActiveOrPendingGrants(\n      ctx,\n      businessSub.dodoSubscriptionId,\n      now,\n    );\n    if (currentCount + newEmails.length > MAX_SEATS) {\n      throw new ConvexError({ kind: \"SEAT_CAP_REACHED\" });\n    }\n\n    for (const email of newEmails) {\n      const grantId = await ctx.db.insert(\"businessProGrants\", {\n        businessSubscriptionId: businessSub.dodoSubscriptionId,\n        ownerUserId: userId,\n        inviteeEmail: email,\n        domain: ownerDomain,\n        status: \"pending\",\n        createdAt: now,\n        expiresAt: now + INVITE_TTL_MS,\n      });\n\n      const token = await signBusinessInviteToken(grantId);\n      await ctx.scheduler.runAfter(\n        0,\n        internal.payments.businessSeats.sendBusinessInviteEmail,\n        { inviteeEmail: email, ownerEmail, grantId, token },","sourceCodeStart":193,"sourceCodeEnd":229,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/payments/businessSeats.ts#L193-L229","documentation":"Thrown by the `inviteSeats` mutation when adding the new (non-duplicate) invitees would exceed the Business plan hard cap of 4 seats (MAX_SEATS). Both accepted grants and un-expired pending grants count toward the limit, so outstanding invites still hold a slot. The check runs under a per-subscription OCC lock (businessSeatLocks) to prevent two concurrent invite calls from racing past the cap.","triggerScenarios":"Calling `inviteSeats({ emails })` where `countActiveOrPendingGrants(...) + newEmails.length > 4`. For example, 3 accepted seats plus a 2-email invite, or 4 pending invites plus any new email. Duplicate/already-pending emails are deduped before the count, so they do not consume additional slots.","commonSituations":"Owner tries to invite a 5th teammate without first revoking an unused pending invite; forgets that pending invites consume a slot until they expire (14-day TTL); batch-inviting more users than remaining capacity in a single call.","solutions":["Revoke an unused pending grant via `removeSeat` before inviting new teammates to free a slot","Reduce the number of emails in the `inviteSeats` call so currentCount + newEmails.length <= 4","Wait for a pending invite to pass its 14-day expiry, which releases its slot automatically","If more than 4 seats are genuinely required, upgrade the plan or contact support — the cap is not adjustable client-side"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// Before calling inviteSeats, query the count of active/pending grants for the\n// Business subscription and ensure count + newEmails.length <= 4 (MAX_SEATS).\nconst grants = await convex.query(api.payments.businessSeats.listGrantsForOwner);\nconst active = grants.filter(g => g.status === 'accepted' || (g.status === 'pending' && g.expiresAt > Date.now()));\nconst room = 4 - active.length;\nif (newEmails.length > room) {\n  // surface 'remove a seat or reduce invite count' to the user instead of calling\n}","typeGuard":null,"tryCatchPattern":"try {\n  await convex.mutation(api.payments.businessSeats.inviteSeats, { emails });\n} catch (err) {\n  if (err.data?.kind === 'SEAT_CAP_REACHED') {\n    // show 'seat cap reached' UI; offer to revoke a pending invite\n  } else { throw err; }\n}","preventionTips":["Show the current active+pending seat count in the invite UI before submission","Encourage owners to revoke unused pending invites to free slots","Remember pending (un-expired) invites consume a slot until they expire"],"tags":["payments","business-seats","seats","convex","authorization"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}