{"record":{"id":"dc70ccac1aeef7ec","repo":"koala73/worldmonitor","slug":"invitee-domain-mismatch","errorCode":"INVITEE_DOMAIN_MISMATCH","errorMessage":"INVITEE_DOMAIN_MISMATCH","messagePattern":"INVITEE_DOMAIN_MISMATCH","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/businessSeats.ts","lineNumber":188,"sourceCode":"\n    // Separate new invites from duplicates before the cap check so a duplicate\n    // re-invite is idempotent even when the cap is full.\n    const newEmails: string[] = [];\n    const results: Array<{\n      email: string;\n      grantId: string;\n      status: \"created\" | \"already_pending\" | \"already_accepted\";\n    }> = [];\n\n    for (const email of emails) {\n      if (email === normalizedOwnerEmail) {\n        throw new ConvexError({ kind: \"CANNOT_INVITE_SELF\" });\n      }\n      if (!isCorporateDomain(email)) {\n        throw new ConvexError({ kind: \"INVITEE_DOMAIN_NOT_CORPORATE\" });\n      }\n      if (!sameDomain(ownerEmail, email)) {\n        throw new ConvexError({ kind: \"INVITEE_DOMAIN_MISMATCH\" });\n      }\n\n      const existing = existingByEmail.get(email);\n      if (existing) {\n        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,","sourceCodeStart":170,"sourceCodeEnd":206,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/payments/businessSeats.ts#L170-L206","documentation":"Thrown by `inviteSeats` per-email when an invitee's domain differs from the owner's domain (`sameDomain` returns false). Business seats are restricted to the SAME corporate domain as the owner, preventing cross-company seat sharing. Object-typed ConvexError (`{ kind: \"INVITEE_DOMAIN_MISMATCH\" }`). Fires after the corporate-domain check, so both addresses are corporate but on different companies.","triggerScenarios":"Owner on `@acme.com` invites `teammate@othercorp.com` — both are valid corporate domains but they differ. Any invitee whose domain doesn't match the owner's.","commonSituations":"Owner invites a contractor/partner at another company; a teammate who uses their previous employer's email; merge of two companies where emails still differ; typo in the domain portion.","solutions":["Only invite emails on the same domain as the owner's account email.","Client-side, enforce `sameDomain(ownerEmail, inviteeEmail)` before submit.","If cross-company collaboration is intended, that is intentionally unsupported — use a separate Business subscription per company."],"exampleFix":"// before\nawait inviteSeats({ emails: [\"teammate@othercorp.com\"] }); // owner is @acme.com\n\n// after\nawait inviteSeats({ emails: [\"teammate@acme.com\"] });","handlingStrategy":"validation","validationCode":"const ownerDomain = ownerEmail.split(\"@\").pop()!.toLowerCase();\nfor (const e of emails) {\n  const d = (e.split(\"@\").pop() ?? \"\").toLowerCase();\n  if (d !== ownerDomain) {\n    showToast(`${e} must be on @${ownerDomain}.`);\n    return;\n  }\n}\nawait inviteSeats({ emails });","typeGuard":"function sameDomain(owner: string, invitee: string): boolean {\n  const a = owner.split(\"@\").pop()?.toLowerCase();\n  const b = invitee.split(\"@\").pop()?.toLowerCase();\n  return !!a && !!b && a === b;\n}","tryCatchPattern":null,"preventionTips":["Pre-fill the company domain and only allow emails matching it.","Reject cross-domain invitees client-side before submit."],"tags":["validation","convex","business-seats","billing","email","domain"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}