{"record":{"id":"dd93194277fd110e","repo":"koala73/worldmonitor","slug":"legacy-comp-source-requires-audit","errorCode":"LEGACY_COMP_SOURCE_REQUIRES_AUDIT","errorMessage":"LEGACY_COMP_SOURCE_REQUIRES_AUDIT","messagePattern":"LEGACY_COMP_SOURCE_REQUIRES_AUDIT","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/billing.ts","lineNumber":3829,"sourceCode":"\n    // Move entitlement rows first, then let the shared recompute path derive\n    // the final paid/free state from the post-claim subscriptions. If the\n    // anonymous row carried a future complimentary floor, transfer it only\n    // when it does not undercut stronger current real-user coverage.\n    const recomputeTimestamp = Date.now();\n    if (anonEntitlement) {\n      const existingEntitlement = await ctx.db\n        .query(\"entitlements\")\n        .withIndex(\"by_userId\", (q) => q.eq(\"userId\", realUserId))\n        .first();\n      if (existingEntitlement) {\n        const anonCompUntil = anonEntitlement.compUntil ?? 0;\n        const existingCompUntil = existingEntitlement.compUntil ?? 0;\n        const anonCompActive = anonCompUntil > recomputeTimestamp;\n        const existingCompActive = existingCompUntil > recomputeTimestamp;\n        if (anonCompActive && existingCompActive\n          && Boolean(anonEntitlement.compPlanKey) !== Boolean(existingEntitlement.compPlanKey)) {\n          throw new ConvexError({ kind: \"LEGACY_COMP_SOURCE_REQUIRES_AUDIT\" });\n        }\n        if (anonEntitlement.compPlanKey && anonCompUntil > recomputeTimestamp) {\n          const existingCompIsStronger = existingEntitlement.compPlanKey\n            && existingCompUntil > recomputeTimestamp\n            && compareEntitlementPlans(\n              { planKey: existingEntitlement.compPlanKey, validUntil: existingCompUntil },\n              { planKey: anonEntitlement.compPlanKey, validUntil: anonCompUntil },\n            ) >= 0;\n          await ctx.db.patch(existingEntitlement._id, {\n            compPlanKey: existingCompIsStronger\n              ? existingEntitlement.compPlanKey\n              : anonEntitlement.compPlanKey,\n            compUntil: Math.max(existingCompUntil, anonCompUntil),\n          });\n        } else if (anonCompUntil > existingCompUntil && anonCompUntil > recomputeTimestamp) {\n          const realSubscriptions = await ctx.db\n            .query(\"subscriptions\")\n            .withIndex(\"by_userId\", (q) => q.eq(\"userId\", realUserId))","sourceCodeStart":3811,"sourceCodeEnd":3847,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/convex/payments/billing.ts#L3811-L3847","documentation":"During entitlement merge (merging an anonymous/legacy entitlement into an existing user entitlement), both sides have an ACTIVE comp (complimentary) period but the compPlanKey presence differs — meaning one comp came from a legacy source without a plan key. The system cannot safely decide which comp to keep, so it throws to force a manual audit instead of silently picking one.","triggerScenarios":"Recompute/merge runs where anonEntitlement.compUntil and existingEntitlement.compUntil are both > recomputeTimestamp AND Boolean(anonEntitlement.compPlanKey) !== Boolean(existingEntitlement.compPlanKey) — i.e. one active comp has a plan key and the other doesn't.","commonSituations":"A user claims an account after using an anonymous comp granted before compPlanKey was introduced; data migration mixes pre- and post-planKey comp records; a comp was granted by a legacy script that left compPlanKey null.","solutions":["Audit the affected entitlements manually and backfill/normalize compPlanKey (or clear one comp period) before retrying the merge.","Identify the legacy comp grant source and update it to always set compPlanKey.","As an operator, patch one of the two entitlements so only one active comp exists, then re-run the recompute."],"exampleFix":"// before (data)\n{ compUntil: 1735689600000, compPlanKey: undefined }  // legacy comp missing plan key\n// after\nawait ctx.db.patch(ent._id, { compPlanKey: \"pro_monthly\" });  // backfill, then re-run merge","handlingStrategy":"try-catch","validationCode":"const conflict = anonCompActive && existingCompActive &&\n  Boolean(anonEntitlement.compPlanKey) !== Boolean(existingEntitlement.compPlanKey);\nif (conflict) await auditLegacyCompSource(anonEntitlement, existingEntitlement); // resolve before merge","typeGuard":null,"tryCatchPattern":"try {\n  await mergeEntitlements(args);\n} catch (e) {\n  if (isConvexError(e) && e.data?.kind === \"LEGACY_COMP_SOURCE_REQUIRES_AUDIT\") {\n    enqueueEntitlementAudit(e.data, args.userId);\n  }\n}","preventionTips":["Always set compPlanKey when granting comp time.","Backfill legacy entitlements before enabling account-claim merges.","Monitor for entitlements with active compUntil but null compPlanKey."],"tags":["billing","entitlements","data-migration","invariant"],"backgroundTag":"internal-invariant-violation","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-09-15T16:44:39.439Z","contentChangedAt":"2026-09-15T16:44:39.439Z","schemaVersion":2},"datasetVersion":"2026-09-15T18:17:12.389Z"}