| |
| |
| |
| |
| |
| |
| |
|
|
| import { MutationCtx, internalMutation } from "../_generated/server"; |
| import { v } from "convex/values"; |
| import { internal } from "../_generated/api"; |
| import { getFeaturesForPlan } from "../lib/entitlements"; |
| import { |
| PLAN_PRECEDENCE, |
| LEGACY_PRODUCT_ALIASES, |
| resolveProductToPlan, |
| } from "../config/productCatalog"; |
| import { ANON_ID_V4_REGEX, verifyUserId } from "../lib/identitySigning"; |
| import { DEV_USER_ID, isDev } from "../lib/auth"; |
|
|
| |
| |
| |
|
|
| interface DodoCustomer { |
| customer_id?: string; |
| email?: string; |
| } |
|
|
| interface DodoSubscriptionData { |
| subscription_id: string; |
| product_id: string; |
| status?: string; |
| customer?: DodoCustomer; |
| previous_billing_date?: string | number | Date; |
| next_billing_date?: string | number | Date; |
| cancelled_at?: string | number | Date; |
| metadata?: Record<string, string>; |
| recurring_pre_tax_amount?: number; |
| currency?: string; |
| tax_inclusive?: boolean; |
| discount_id?: string | null; |
| } |
|
|
| interface DodoPaymentData { |
| payment_id: string; |
| customer?: DodoCustomer; |
| total_amount?: number; |
| amount?: number; |
| currency?: string; |
| subscription_id?: string; |
| metadata?: Record<string, string>; |
| |
| |
| |
| status?: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type RoutedPaymentEvent = |
| | "payment.succeeded" |
| | "payment.failed" |
| | "payment.processing" |
| | "payment.cancelled" |
| | "refund.succeeded" |
| | "refund.failed"; |
|
|
| type PaymentEventStatusValue = |
| | "succeeded" |
| | "failed" |
| | "processing" |
| | "requires_customer_action" |
| | "cancelled"; |
|
|
| |
| |
| |
| |
| function derivePaymentEventStatus( |
| eventType: RoutedPaymentEvent, |
| data: DodoPaymentData, |
| ): PaymentEventStatusValue { |
| switch (eventType) { |
| case "payment.succeeded": |
| case "refund.succeeded": |
| return "succeeded"; |
| case "payment.failed": |
| case "refund.failed": |
| return "failed"; |
| case "payment.cancelled": |
| return "cancelled"; |
| case "payment.processing": |
| |
| |
| |
| return data.status === "requires_customer_action" |
| ? "requires_customer_action" |
| : "processing"; |
| default: { |
| const _exhaustive: never = eventType; |
| throw new Error( |
| `[webhook] derivePaymentEventStatus: unrouted event ${String(_exhaustive)}`, |
| ); |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export function isNewerEvent( |
| existingUpdatedAt: number, |
| incomingTimestamp: number, |
| ): boolean { |
| return incomingTimestamp > existingUpdatedAt; |
| } |
|
|
| |
| |
| |
| |
| |
| const ENTITLEMENT_CACHE_RESYNC_DELAY_MS = 15_000; |
|
|
| |
| |
| |
| |
| export async function upsertEntitlements( |
| ctx: MutationCtx, |
| userId: string, |
| planKey: string, |
| validUntil: number, |
| updatedAt: number, |
| ): Promise<void> { |
| const existing = await ctx.db |
| .query("entitlements") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .first(); |
|
|
| const features = getFeaturesForPlan(planKey); |
|
|
| if (existing) { |
| await ctx.db.patch(existing._id, { |
| planKey, |
| features, |
| validUntil, |
| updatedAt, |
| }); |
| } else { |
| |
| |
| |
| |
| |
| const existingNow = await ctx.db |
| .query("entitlements") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .first(); |
| if (existingNow) { |
| await ctx.db.patch(existingNow._id, { planKey, features, validUntil, updatedAt }); |
| } else { |
| await ctx.db.insert("entitlements", { |
| userId, |
| planKey, |
| features, |
| validUntil, |
| updatedAt, |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| if (process.env.UPSTASH_REDIS_REST_URL) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.cacheActions.syncEntitlementCache, |
| { userId, planKey, features, validUntil }, |
| ); |
| |
| |
| |
| |
| |
| |
| |
| |
| await ctx.scheduler.runAfter( |
| ENTITLEMENT_CACHE_RESYNC_DELAY_MS, |
| internal.payments.cacheActions.resyncEntitlementCacheFromDb, |
| { userId }, |
| ); |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| export type SubscriptionStatus = "active" | "on_hold" | "cancelled" | "expired"; |
|
|
| type SubscriptionRow = { |
| _id: import("../_generated/dataModel").Id<"subscriptions">; |
| userId: string; |
| dodoSubscriptionId: string; |
| planKey: string; |
| status: SubscriptionStatus; |
| currentPeriodEnd: number; |
| }; |
|
|
| |
| |
| |
| |
| |
| export function isCoveringAt<T extends Pick<SubscriptionRow, "status" | "currentPeriodEnd">>( |
| s: T, |
| at: number, |
| ): boolean { |
| return ( |
| s.status === "active" || |
| s.status === "on_hold" || |
| (s.status === "cancelled" && s.currentPeriodEnd > at) |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function isLapsedAt< |
| T extends Pick<SubscriptionRow, "status" | "currentPeriodEnd"> & { |
| renewalVerificationState?: "pending" | "failed" | "lapsed"; |
| }, |
| >(s: T, at: number): boolean { |
| if (s.status === "expired") return true; |
| if (s.status === "cancelled") return s.currentPeriodEnd < at; |
| return s.status === "active" && s.renewalVerificationState === "lapsed"; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function compareSubscriptionsByCoverage< |
| T extends Pick<SubscriptionRow, "planKey" | "currentPeriodEnd">, |
| >(a: T, b: T): number { |
| const tierDelta = getFeaturesForPlan(a.planKey).tier - getFeaturesForPlan(b.planKey).tier; |
| if (tierDelta !== 0) return tierDelta; |
| const rankDelta = (PLAN_PRECEDENCE[a.planKey] ?? 0) - (PLAN_PRECEDENCE[b.planKey] ?? 0); |
| if (rankDelta !== 0) return rankDelta; |
| return a.currentPeriodEnd - b.currentPeriodEnd; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function pickBestCoveringSub( |
| ctx: MutationCtx, |
| userId: string, |
| at: number, |
| ): Promise<SubscriptionRow | null> { |
| const candidates = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .collect(); |
|
|
| let best: SubscriptionRow | null = null; |
| for (const s of candidates) { |
| if (!isCoveringAt(s, at)) continue; |
| if (best === null || compareSubscriptionsByCoverage(s, best) > 0) { |
| best = s as SubscriptionRow; |
| } |
| } |
| return best; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async function pickBestAcceptedBusinessGrant( |
| ctx: MutationCtx, |
| userId: string, |
| at: number, |
| ): Promise<{ planKey: string; currentPeriodEnd: number } | null> { |
| const acceptedGrants = await ctx.db |
| .query("businessProGrants") |
| .withIndex("by_inviteeUserId", (q) => q.eq("inviteeUserId", userId)) |
| .filter((q) => q.eq(q.field("status"), "accepted")) |
| .collect(); |
|
|
| let best: { planKey: string; currentPeriodEnd: number } | null = null; |
| for (const grant of acceptedGrants) { |
| const businessSub = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", grant.businessSubscriptionId), |
| ) |
| .unique(); |
| |
| |
| |
| |
| |
| |
| if (!businessSub || businessSub.planKey !== "api_business" || !isCoveringAt(businessSub, at)) continue; |
|
|
| const candidate = { planKey: "pro_monthly", currentPeriodEnd: businessSub.currentPeriodEnd }; |
| if (best === null || compareSubscriptionsByCoverage(candidate, best) > 0) { |
| best = candidate; |
| } |
| } |
| return best; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function recomputeEntitlementFromAllSubs( |
| ctx: MutationCtx, |
| userId: string, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const entitlement = await ctx.db |
| .query("entitlements") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .first(); |
| if (entitlement?.compUntil && entitlement.compUntil > eventTimestamp) { |
| console.log( |
| `[subscriptionHelpers] recompute for ${userId} — comp floor active until ${new Date(entitlement.compUntil).toISOString()}, preserving entitlement`, |
| ); |
| return; |
| } |
|
|
| const bestSub = await pickBestCoveringSub(ctx, userId, eventTimestamp); |
| const bestGrant = await pickBestAcceptedBusinessGrant(ctx, userId, eventTimestamp); |
|
|
| |
| |
| |
| const best = |
| bestSub && bestGrant |
| ? compareSubscriptionsByCoverage(bestSub, bestGrant) >= 0 |
| ? { planKey: bestSub.planKey, validUntil: bestSub.currentPeriodEnd } |
| : { planKey: bestGrant.planKey, validUntil: bestGrant.currentPeriodEnd } |
| : bestSub |
| ? { planKey: bestSub.planKey, validUntil: bestSub.currentPeriodEnd } |
| : bestGrant |
| ? { planKey: bestGrant.planKey, validUntil: bestGrant.currentPeriodEnd } |
| : null; |
|
|
| if (best) { |
| await upsertEntitlements(ctx, userId, best.planKey, best.validUntil, eventTimestamp); |
| return; |
| } |
|
|
| |
| |
| |
| await upsertEntitlements(ctx, userId, "free", eventTimestamp, eventTimestamp); |
| } |
|
|
| |
| |
| |
| |
| export const recomputeEntitlementForUser = internalMutation({ |
| args: { userId: v.string(), eventTimestamp: v.optional(v.number()) }, |
| handler: async (ctx, args) => { |
| await recomputeEntitlementFromAllSubs(ctx, args.userId, args.eventTimestamp ?? Date.now()); |
| return { ok: true as const }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const revokeBusinessProGrantsIfNotCovering = internalMutation({ |
| args: { dodoSubscriptionId: v.string() }, |
| handler: async (ctx, args) => { |
| const sub = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", args.dodoSubscriptionId), |
| ) |
| .unique(); |
| if (!sub) return { ok: true as const, revoked: 0 }; |
|
|
| const now = Date.now(); |
| if (isCoveringAt(sub, now)) return { ok: true as const, revoked: 0 }; |
|
|
| const { revoked } = await revokeBusinessProGrantsForSubscription( |
| ctx, |
| args.dodoSubscriptionId, |
| now, |
| ); |
| return { ok: true as const, revoked }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const reconcileBusinessProGrants = internalMutation({ |
| args: {}, |
| handler: async (ctx) => { |
| const now = Date.now(); |
| const grants = await ctx.db.query("businessProGrants").collect(); |
| const live = grants.filter((g) => g.status === "accepted" || g.status === "pending"); |
|
|
| let checked = 0; |
| let revoked = 0; |
| let failed = 0; |
| for (const grant of live) { |
| checked += 1; |
| |
| |
| |
| |
| try { |
| const sub = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", grant.businessSubscriptionId), |
| ) |
| .unique(); |
| const stillValid = sub !== null && sub.planKey === "api_business" && isCoveringAt(sub, now); |
| if (stillValid) continue; |
|
|
| await ctx.db.patch(grant._id, { status: "revoked" }); |
| revoked += 1; |
| if (grant.inviteeUserId) { |
| await recomputeEntitlementFromAllSubs(ctx, grant.inviteeUserId, now); |
| if (process.env.RESEND_API_KEY) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.businessSeats.sendTeamAccessEndedEmail, |
| { inviteeEmail: grant.inviteeEmail }, |
| ); |
| } |
| } |
| } catch (err) { |
| failed += 1; |
| |
| |
| |
| |
| console.error( |
| `[subscriptionHelpers] reconcileBusinessProGrants: failed to reconcile grant ${grant._id} — continuing with remaining grants`, |
| err, |
| ); |
| } |
| } |
| return { ok: true as const, checked, revoked, failed }; |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const FALLBACK_PLAN_KEY = "enterprise"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function resolvePlanKey( |
| ctx: MutationCtx, |
| dodoProductId: string, |
| ): Promise<string> { |
| const mapping = await ctx.db |
| .query("productPlans") |
| .withIndex("by_dodoProductId", (q) => q.eq("dodoProductId", dodoProductId)) |
| .unique(); |
| if (mapping) return mapping.planKey; |
|
|
| |
| |
| |
| |
| |
| const aliasedPlan = LEGACY_PRODUCT_ALIASES[dodoProductId]; |
| if (aliasedPlan) { |
| console.warn( |
| `[subscriptionHelpers] Resolved "${dodoProductId}" via legacy alias → "${aliasedPlan}". ` + |
| `Consider updating the subscription to the current product ID.`, |
| ); |
| return aliasedPlan; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| const catalogPlan = resolveProductToPlan(dodoProductId); |
| if (catalogPlan) { |
| |
| |
| |
| |
| console.error( |
| `[subscriptionHelpers] Dodo product ID "${dodoProductId}" is in PRODUCT_CATALOG ` + |
| `but NOT in the productPlans table — resolved to "${catalogPlan}" from the code ` + |
| `catalog instead of over-granting "${FALLBACK_PLAN_KEY}". ` + |
| `ACTION REQUIRED: re-run seedProductPlans so webhook resolution stops depending ` + |
| `on the deployed catalog. See scripts/audit-dodo-catalog.cjs.`, |
| ); |
| return catalogPlan; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| console.error( |
| `[subscriptionHelpers] Unknown Dodo product ID "${dodoProductId}" — ` + |
| `not in productPlans table and not in LEGACY_PRODUCT_ALIASES. ` + |
| `Falling back to "${FALLBACK_PLAN_KEY}" (over-grant) so the customer ` + |
| `keeps full paid entitlement until catalog is fixed. ` + |
| `ACTION REQUIRED: add this product to ` + |
| `convex/config/productCatalog.ts (LEGACY_PRODUCT_ALIASES or PRODUCT_CATALOG) ` + |
| `and re-run seedProductPlans. See scripts/audit-dodo-catalog.cjs.`, |
| ); |
| return FALLBACK_PLAN_KEY; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function resolveUserId( |
| ctx: MutationCtx, |
| dodoCustomerId: string, |
| metadata?: Record<string, string>, |
| ): Promise<string> { |
| |
| if (metadata?.wm_user_id && metadata?.wm_user_id_sig) { |
| const isValid = await verifyUserId(metadata.wm_user_id, metadata.wm_user_id_sig); |
| if (isValid) { |
| return metadata.wm_user_id; |
| } |
| console.warn( |
| `[subscriptionHelpers] Invalid HMAC signature for wm_user_id="${metadata.wm_user_id}" — ignoring metadata`, |
| ); |
| } else if (metadata?.wm_user_id && !metadata?.wm_user_id_sig) { |
| console.warn( |
| `[subscriptionHelpers] Unsigned wm_user_id="${metadata.wm_user_id}" — ignoring (requires HMAC signature)`, |
| ); |
| } |
|
|
| |
| if (dodoCustomerId) { |
| const customer = await ctx.db |
| .query("customers") |
| .withIndex("by_dodoCustomerId", (q) => |
| q.eq("dodoCustomerId", dodoCustomerId), |
| ) |
| .first(); |
| if (customer?.userId) { |
| return customer.userId; |
| } |
| } |
|
|
| |
| if (isDev) { |
| console.warn( |
| `[subscriptionHelpers] No user identity found for customer="${dodoCustomerId}" — using dev fallback "${DEV_USER_ID}"`, |
| ); |
| return DEV_USER_ID; |
| } |
|
|
| throw new Error( |
| `[subscriptionHelpers] Cannot resolve userId: no verified metadata, no customer record, no dodoCustomerId.`, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function toEpochMs(value: unknown, fieldName?: string, fallback?: number): number { |
| if (typeof value === "number") return value; |
| if (typeof value === "string" || value instanceof Date) { |
| const ms = new Date(value).getTime(); |
| if (!Number.isNaN(ms)) return ms; |
| } |
| const fb = fallback ?? Date.now(); |
| console.warn( |
| `[subscriptionHelpers] toEpochMs: missing or invalid ${fieldName ?? "date"} value (${String(value)}) — falling back to ${fallback !== undefined ? "eventTimestamp" : "Date.now()"}`, |
| ); |
| return fb; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function mergeDodoCustomerId( |
| data: DodoSubscriptionData, |
| existing: { dodoCustomerId?: string }, |
| ): string | undefined { |
| const incoming = data.customer?.customer_id; |
| if (typeof incoming === "string" && incoming.length > 0) return incoming; |
| return existing.dodoCustomerId; |
| } |
|
|
| function preferExistingCustomerOwner( |
| existingCustomerUserId: string | undefined, |
| resolvedUserId: string, |
| ): string { |
| if ( |
| existingCustomerUserId !== undefined && |
| ANON_ID_V4_REGEX.test(resolvedUserId) && |
| !ANON_ID_V4_REGEX.test(existingCustomerUserId) |
| ) { |
| return existingCustomerUserId; |
| } |
| return resolvedUserId; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function handleSubscriptionActive( |
| ctx: MutationCtx, |
| data: DodoSubscriptionData, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const planKey = await resolvePlanKey(ctx, data.product_id); |
|
|
| const currentPeriodStart = toEpochMs(data.previous_billing_date, "previous_billing_date", eventTimestamp); |
| const currentPeriodEnd = toEpochMs(data.next_billing_date, "next_billing_date", eventTimestamp); |
|
|
| const existing = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id), |
| ) |
| .unique(); |
|
|
| |
| |
| |
| |
| |
| |
| const incomingDodoCustomerId = |
| typeof data.customer?.customer_id === "string" && data.customer.customer_id.length > 0 |
| ? data.customer.customer_id |
| : undefined; |
|
|
| if (existing && !isNewerEvent(existing.updatedAt, eventTimestamp)) return; |
|
|
| const existingCustomer = incomingDodoCustomerId |
| ? await ctx.db |
| .query("customers") |
| .withIndex("by_dodoCustomerId", (q) => |
| q.eq("dodoCustomerId", incomingDodoCustomerId), |
| ) |
| .first() |
| : null; |
| const resolvedUserId = existing |
| ? existing.userId |
| : await resolveUserId(ctx, incomingDodoCustomerId ?? "", data.metadata); |
| const userId = existing |
| ? existing.userId |
| : preferExistingCustomerOwner(existingCustomer?.userId, resolvedUserId); |
| |
| |
| |
| |
| const priorSubscriptions = existing |
| ? [existing] |
| : await ctx.db |
| .query("subscriptions") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .take(50); |
| const hasCurrentAccess = priorSubscriptions.some( |
| (subscription) => |
| !isLapsedAt(subscription, eventTimestamp) && |
| isCoveringAt(subscription, eventTimestamp), |
| ); |
| const wasLapsed = |
| !hasCurrentAccess && |
| priorSubscriptions.some((subscription) => |
| isLapsedAt(subscription, eventTimestamp), |
| ); |
|
|
| if (existing) { |
| await ctx.db.patch(existing._id, { |
| userId, |
| status: "active", |
| dodoProductId: data.product_id, |
| planKey, |
| currentPeriodStart, |
| currentPeriodEnd, |
| dodoCustomerId: incomingDodoCustomerId ?? existing.dodoCustomerId, |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| |
| |
| |
| |
| lastReconcileAttemptAt: undefined, |
| reconcileFailureCount: undefined, |
| reconcileNotFoundCount: undefined, |
| renewalVerificationState: undefined, |
| renewalVerificationAttemptAt: undefined, |
| }); |
| } else { |
| await ctx.db.insert("subscriptions", { |
| userId, |
| dodoSubscriptionId: data.subscription_id, |
| dodoProductId: data.product_id, |
| planKey, |
| status: "active", |
| currentPeriodStart, |
| currentPeriodEnd, |
| dodoCustomerId: incomingDodoCustomerId, |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const referralCode = data.metadata?.affonso_referral; |
| if (typeof referralCode === "string" && referralCode.length > 0) { |
| const referrer = await ctx.db |
| .query("userReferralCodes") |
| .withIndex("by_code", (q) => q.eq("code", referralCode)) |
| .first(); |
| if (referrer) { |
| const refereeEmail = (data.customer?.email ?? "").trim().toLowerCase(); |
| if (refereeEmail) { |
| const existingCredit = await ctx.db |
| .query("userReferralCredits") |
| .withIndex("by_referrer_email", (q) => |
| q.eq("referrerUserId", referrer.userId).eq("refereeEmail", refereeEmail), |
| ) |
| .first(); |
| if (!existingCredit) { |
| await ctx.db.insert("userReferralCredits", { |
| referrerUserId: referrer.userId, |
| refereeEmail, |
| createdAt: eventTimestamp, |
| }); |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| |
| await recomputeEntitlementFromAllSubs(ctx, userId, eventTimestamp); |
|
|
| |
| const email = data.customer?.email ?? ""; |
| const normalizedEmail = email.trim().toLowerCase(); |
|
|
| if (incomingDodoCustomerId) { |
| if (existingCustomer) { |
| await ctx.db.patch(existingCustomer._id, { |
| userId, |
| email, |
| normalizedEmail, |
| updatedAt: eventTimestamp, |
| }); |
| } else { |
| await ctx.db.insert("customers", { |
| userId, |
| dodoCustomerId: incomingDodoCustomerId, |
| email, |
| normalizedEmail, |
| createdAt: eventTimestamp, |
| updatedAt: eventTimestamp, |
| }); |
| } |
| } |
|
|
| |
| |
| |
| if (!email) { |
| console.warn( |
| `[subscriptionHelpers] subscription.active: no customer email — skipping welcome email (subscriptionId=${data.subscription_id})`, |
| ); |
| } else if (wasLapsed) { |
| if (process.env.RESEND_API_KEY) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.subscriptionEmails.sendReactivationEmail, |
| { userEmail: email, planKey }, |
| ); |
| console.log(`[subscriptionHelpers] subscription.active: scheduled reactivation email (subscriptionId=${data.subscription_id})`); |
| } else { |
| console.warn( |
| `[subscriptionHelpers] subscription.active: RESEND_API_KEY not set — skipping reactivation email (subscriptionId=${data.subscription_id})`, |
| ); |
| } |
| } else if (existing) { |
| console.log(`[subscriptionHelpers] subscription.active: existing non-lapsed subscription — skipping email (subscriptionId=${data.subscription_id})`); |
| } else if (process.env.RESEND_API_KEY) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.subscriptionEmails.sendSubscriptionEmails, |
| { |
| userEmail: email, |
| planKey, |
| userId, |
| recurringPreTaxAmount: data.recurring_pre_tax_amount, |
| currency: data.currency, |
| taxInclusive: data.tax_inclusive, |
| discountId: data.discount_id ?? undefined, |
| }, |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function handleSubscriptionRenewed( |
| ctx: MutationCtx, |
| data: DodoSubscriptionData, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const existing = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id), |
| ) |
| .unique(); |
|
|
| if (!existing) { |
| console.warn( |
| `[subscriptionHelpers] Renewal for unknown subscription ${data.subscription_id} -- skipping`, |
| ); |
| return; |
| } |
|
|
| if (!isNewerEvent(existing.updatedAt, eventTimestamp)) return; |
|
|
| const currentPeriodStart = toEpochMs(data.previous_billing_date, "previous_billing_date", eventTimestamp); |
| const currentPeriodEnd = toEpochMs(data.next_billing_date, "next_billing_date", eventTimestamp); |
|
|
| await ctx.db.patch(existing._id, { |
| status: "active", |
| currentPeriodStart, |
| currentPeriodEnd, |
| dodoCustomerId: mergeDodoCustomerId(data, existing), |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| |
| |
| |
| lastReconcileAttemptAt: undefined, |
| reconcileFailureCount: undefined, |
| reconcileNotFoundCount: undefined, |
| renewalVerificationState: undefined, |
| renewalVerificationAttemptAt: undefined, |
| }); |
|
|
| |
| |
| await recomputeEntitlementFromAllSubs(ctx, existing.userId, eventTimestamp); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function handleSubscriptionOnHold( |
| ctx: MutationCtx, |
| data: DodoSubscriptionData, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const existing = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id), |
| ) |
| .unique(); |
|
|
| if (!existing) { |
| console.warn( |
| `[subscriptionHelpers] on_hold for unknown subscription ${data.subscription_id} -- skipping`, |
| ); |
| return; |
| } |
|
|
| if (!isNewerEvent(existing.updatedAt, eventTimestamp)) return; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const enteringHold = existing.status !== "on_hold"; |
| const onHoldAt = enteringHold ? eventTimestamp : (existing.onHoldAt ?? existing.updatedAt); |
|
|
| await ctx.db.patch(existing._id, { |
| status: "on_hold", |
| onHoldAt, |
| dodoCustomerId: mergeDodoCustomerId(data, existing), |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| }); |
|
|
| console.warn( |
| `[subscriptionHelpers] Subscription ${data.subscription_id} on hold -- payment failure`, |
| ); |
| |
|
|
| |
| |
| |
| |
| if (enteringHold && process.env.RESEND_API_KEY) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.subscriptionEmails.sendDunningEmail, |
| { |
| dodoSubscriptionId: data.subscription_id, |
| step: "dunning_day0", |
| episodeAt: onHoldAt, |
| }, |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function revokeBusinessProGrantsForSubscription( |
| ctx: MutationCtx, |
| dodoSubscriptionId: string, |
| eventTimestamp: number, |
| ): Promise<{ revoked: number; failed: number }> { |
| const grants = await ctx.db |
| .query("businessProGrants") |
| .withIndex("by_businessSubscriptionId", (q) => |
| q.eq("businessSubscriptionId", dodoSubscriptionId), |
| ) |
| .collect(); |
|
|
| let revoked = 0; |
| let failed = 0; |
| for (const grant of grants) { |
| if (grant.status !== "accepted" && grant.status !== "pending") continue; |
| |
| |
| |
| |
| |
| |
| try { |
| await ctx.db.patch(grant._id, { status: "revoked" }); |
| revoked += 1; |
| if (grant.inviteeUserId) { |
| await recomputeEntitlementFromAllSubs(ctx, grant.inviteeUserId, eventTimestamp); |
| |
| if (process.env.RESEND_API_KEY) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.businessSeats.sendTeamAccessEndedEmail, |
| { inviteeEmail: grant.inviteeEmail }, |
| ); |
| } |
| } |
| } catch (err) { |
| failed += 1; |
| |
| |
| |
| |
| |
| |
| |
| console.error( |
| `[subscriptionHelpers] revokeBusinessProGrantsForSubscription: failed to fully process grant ${grant._id} (invitee ${grant.inviteeUserId ?? "unaccepted"}) — grant is revoked, continuing with remaining grants`, |
| err, |
| ); |
| } |
| } |
| return { revoked, failed }; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function handleSubscriptionCancelled( |
| ctx: MutationCtx, |
| data: DodoSubscriptionData, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const existing = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id), |
| ) |
| .unique(); |
|
|
| if (!existing) { |
| console.warn( |
| `[subscriptionHelpers] Cancellation for unknown subscription ${data.subscription_id} -- skipping`, |
| ); |
| return; |
| } |
|
|
| if (!isNewerEvent(existing.updatedAt, eventTimestamp)) return; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const enteringCancelled = existing.status !== "cancelled"; |
| const eventCancelledAt = data.cancelled_at |
| ? toEpochMs(data.cancelled_at, "cancelled_at", eventTimestamp) |
| : eventTimestamp; |
| const cancelledAt = enteringCancelled |
| ? eventCancelledAt |
| : (existing.cancelledAt ?? eventCancelledAt); |
|
|
| await ctx.db.patch(existing._id, { |
| status: "cancelled", |
| cancelledAt, |
| dodoCustomerId: mergeDodoCustomerId(data, existing), |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| }); |
|
|
| |
| |
| |
| |
| if (existing.planKey === "api_business") { |
| if (!isCoveringAt(existing, eventTimestamp)) { |
| await revokeBusinessProGrantsForSubscription(ctx, existing.dodoSubscriptionId, eventTimestamp); |
| } else { |
| await ctx.scheduler.runAfter( |
| Math.max(0, existing.currentPeriodEnd - eventTimestamp), |
| internal.payments.subscriptionHelpers.revokeBusinessProGrantsIfNotCovering, |
| { dodoSubscriptionId: existing.dodoSubscriptionId }, |
| ); |
| } |
| } |
|
|
| |
| } |
|
|
| |
| |
| |
| |
| |
| export async function handleSubscriptionPlanChanged( |
| ctx: MutationCtx, |
| data: DodoSubscriptionData, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const existing = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id), |
| ) |
| .unique(); |
|
|
| if (!existing) { |
| console.warn( |
| `[subscriptionHelpers] Plan change for unknown subscription ${data.subscription_id} -- skipping`, |
| ); |
| return; |
| } |
|
|
| if (!isNewerEvent(existing.updatedAt, eventTimestamp)) return; |
|
|
| const newPlanKey = await resolvePlanKey(ctx, data.product_id); |
| const leftBusinessPlan = existing.planKey === "api_business" && newPlanKey !== "api_business"; |
|
|
| await ctx.db.patch(existing._id, { |
| dodoProductId: data.product_id, |
| planKey: newPlanKey, |
| dodoCustomerId: mergeDodoCustomerId(data, existing), |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| }); |
|
|
| |
| |
| |
| |
| |
| if (leftBusinessPlan) { |
| await revokeBusinessProGrantsForSubscription(ctx, existing.dodoSubscriptionId, eventTimestamp); |
| } |
|
|
| |
| |
| |
| await recomputeEntitlementFromAllSubs(ctx, existing.userId, eventTimestamp); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function handleSubscriptionExpired( |
| ctx: MutationCtx, |
| data: DodoSubscriptionData, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const existing = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id), |
| ) |
| .unique(); |
|
|
| if (!existing) { |
| console.warn( |
| `[subscriptionHelpers] Expiration for unknown subscription ${data.subscription_id} -- skipping`, |
| ); |
| return; |
| } |
|
|
| if (!isNewerEvent(existing.updatedAt, eventTimestamp)) return; |
|
|
| await ctx.db.patch(existing._id, { |
| status: "expired", |
| dodoCustomerId: mergeDodoCustomerId(data, existing), |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| }); |
|
|
| |
| |
| if (existing.planKey === "api_business") { |
| await revokeBusinessProGrantsForSubscription(ctx, existing.dodoSubscriptionId, eventTimestamp); |
| } |
|
|
| |
| |
| |
| |
| await recomputeEntitlementFromAllSubs(ctx, existing.userId, eventTimestamp); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function handleSubscriptionUpdated( |
| ctx: MutationCtx, |
| data: DodoSubscriptionData, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const status = (data.status ?? "").toString(); |
| switch (status) { |
| case "active": |
| return handleSubscriptionActive(ctx, data, eventTimestamp); |
| case "on_hold": |
| return handleSubscriptionOnHold(ctx, data, eventTimestamp); |
| case "cancelled": |
| return handleSubscriptionCancelled(ctx, data, eventTimestamp); |
| case "expired": |
| return handleSubscriptionExpired(ctx, data, eventTimestamp); |
| default: { |
| console.error( |
| `[handleSubscriptionUpdated] unhandled status="${status}" sub=${data.subscription_id}; ` + |
| `recomputing entitlement defensively. Add a dedicated dispatch case if this status starts ` + |
| `appearing regularly.`, |
| ); |
| const existing = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id), |
| ) |
| .unique(); |
| if (existing && isNewerEvent(existing.updatedAt, eventTimestamp)) { |
| await ctx.db.patch(existing._id, { |
| dodoCustomerId: mergeDodoCustomerId(data, existing), |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| }); |
| await recomputeEntitlementFromAllSubs(ctx, existing.userId, eventTimestamp); |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function handlePaymentOrRefundEvent( |
| ctx: MutationCtx, |
| data: DodoPaymentData, |
| eventType: string, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const userId = await resolveUserId( |
| ctx, |
| data.customer?.customer_id ?? "", |
| data.metadata, |
| ); |
|
|
| const type = eventType.startsWith("refund.") ? "refund" : "charge"; |
| |
| |
| |
| |
| |
| |
| |
| const status = derivePaymentEventStatus(eventType as RoutedPaymentEvent, data); |
|
|
| await ctx.db.insert("paymentEvents", { |
| userId, |
| dodoPaymentId: data.payment_id, |
| type, |
| amount: data.total_amount ?? data.amount ?? 0, |
| currency: data.currency ?? "USD", |
| status, |
| dodoSubscriptionId: data.subscription_id ?? undefined, |
| |
| |
| |
| |
| planKey: data.metadata?.wm_plan_key, |
| rawPayload: data, |
| occurredAt: eventTimestamp, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (eventType === "refund.succeeded" && data.subscription_id) { |
| const sub = await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id ?? ""), |
| ) |
| .unique(); |
| const decision = classifyRefundAlert({ |
| subStatus: sub?.status, |
| subCancelledAt: sub?.cancelledAt, |
| subRawPayload: sub?.rawPayload, |
| subUserId: sub?.userId, |
| refundAmount: data.total_amount ?? data.amount ?? 0, |
| }); |
| if (decision.kind === "alert") { |
| console.error( |
| `[refund-alert] full refund without prior cancellation: ` + |
| `subId=${data.subscription_id} userId=${decision.userId} ` + |
| `refund=${decision.refundAmount} subAmount=${decision.subAmount} ` + |
| `paymentId=${data.payment_id}. Operator likely forgot to cancel ` + |
| `before refund — entitlement remains active until manual cleanup.`, |
| ); |
| |
| } else if (decision.kind === "warn-amount-unknown") { |
| |
| |
| |
| console.warn( |
| `[refund-alert] refund on active sub but cannot classify amount: ` + |
| `subId=${data.subscription_id} userId=${decision.userId} ` + |
| `refund=${decision.refundAmount} (rawPayload.recurring_pre_tax_amount missing)`, |
| ); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export type RefundAlertDecision = |
| | { kind: "alert"; userId: string; refundAmount: number; subAmount: number } |
| | { kind: "warn-amount-unknown"; userId: string; refundAmount: number } |
| | { kind: "no-op"; reason: string }; |
|
|
| export function classifyRefundAlert(input: { |
| subStatus: string | undefined; |
| subCancelledAt: number | undefined; |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| subRawPayload: any; |
| subUserId: string | undefined; |
| refundAmount: number; |
| }): RefundAlertDecision { |
| if (!input.subStatus || !input.subUserId) { |
| return { kind: "no-op", reason: "no-subscription" }; |
| } |
| if (input.subStatus !== "active") { |
| return { kind: "no-op", reason: `sub-status-${input.subStatus}` }; |
| } |
| if (input.subCancelledAt) { |
| return { kind: "no-op", reason: "already-cancelled" }; |
| } |
| const subAmount = typeof input.subRawPayload?.recurring_pre_tax_amount === "number" |
| ? input.subRawPayload.recurring_pre_tax_amount |
| : 0; |
| if (subAmount <= 0) { |
| return { |
| kind: "warn-amount-unknown", |
| userId: input.subUserId, |
| refundAmount: input.refundAmount, |
| }; |
| } |
| |
| |
| const isFullRefund = input.refundAmount >= subAmount * 0.99; |
| if (!isFullRefund) { |
| return { kind: "no-op", reason: "partial-refund" }; |
| } |
| return { |
| kind: "alert", |
| userId: input.subUserId, |
| refundAmount: input.refundAmount, |
| subAmount, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function handleDisputeEvent( |
| ctx: MutationCtx, |
| data: DodoPaymentData, |
| eventType: string, |
| eventTimestamp: number, |
| ): Promise<void> { |
| const existingSubscription = data.subscription_id |
| ? await ctx.db |
| .query("subscriptions") |
| .withIndex("by_dodoSubscriptionId", (q) => |
| q.eq("dodoSubscriptionId", data.subscription_id ?? ""), |
| ) |
| .unique() |
| : null; |
| const userId = existingSubscription?.userId |
| ?? await resolveUserId( |
| ctx, |
| data.customer?.customer_id ?? "", |
| data.metadata, |
| ); |
|
|
| const disputeStatusMap: Record<string, "dispute_opened" | "dispute_won" | "dispute_lost" | "dispute_closed"> = { |
| "dispute.opened": "dispute_opened", |
| "dispute.won": "dispute_won", |
| "dispute.lost": "dispute_lost", |
| "dispute.closed": "dispute_closed", |
| }; |
| const disputeStatus = disputeStatusMap[eventType]; |
| if (!disputeStatus) { |
| console.error(`[handleDisputeEvent] Unknown dispute event type: ${eventType}`); |
| return; |
| } |
|
|
| await ctx.db.insert("paymentEvents", { |
| userId, |
| dodoPaymentId: data.payment_id, |
| type: "charge", |
| amount: data.total_amount ?? data.amount ?? 0, |
| currency: data.currency ?? "USD", |
| status: disputeStatus, |
| dodoSubscriptionId: data.subscription_id ?? undefined, |
| rawPayload: data, |
| occurredAt: eventTimestamp, |
| }); |
|
|
| if (eventType === "dispute.lost") { |
| console.warn( |
| `[subscriptionHelpers] Dispute LOST for user ${userId}, payment ${data.payment_id} — recomputing entitlement`, |
| ); |
|
|
| if (existingSubscription && isNewerEvent(existingSubscription.updatedAt, eventTimestamp)) { |
| await ctx.db.patch(existingSubscription._id, { |
| status: "expired", |
| rawPayload: data, |
| updatedAt: eventTimestamp, |
| }); |
| } |
|
|
| await recomputeEntitlementFromAllSubs(ctx, userId, eventTimestamp); |
| } |
| } |
|
|