koala73/worldmonitor · error · ConvexError

CUSTOMER_PROVENANCE_REQUIRED

CUSTOMER_PROVENANCE_REQUIRED

Error message

CUSTOMER_PROVENANCE_REQUIRED

What it means

When deleting/retaining a subscription, the code resolves the Dodo customer ID from the subscription or raw webhook data and requires a non-empty value: the current user-customer mapping does not prove which customer a subscription belongs to (rows get reassigned by later webhooks). Without an attributable customerId, the deletion cannot preserve provenance, so it throws and the subscription is kept until the customer can be repaired from provider/audit evidence.

Solutions

  1. Repair the subscription's dodoCustomerId from provider (Dodo API) or audit records, then retry the deletion.
  2. If processing a webhook, verify the payload includes the customer id before calling this path.
  3. Check why sub.dodoCustomerId was never written — fix the subscription-creation/webhook handler that should persist it.

Example fix

// before (webhook handler)
await handleSubscriptionDeleted({ id: body.data.id });  // customer id dropped
// after
await handleSubscriptionDeleted({ id: body.data.id, dodoCustomerId: body.data.customer_id });
Defensive patterns

Strategy: validation

Validate before calling

const customerId = sub.dodoCustomerId ?? body?.data?.customer_id;
if (typeof customerId !== "string" || !customerId.trim()) {
  throw new Error("webhook payload missing customer id; repair before deleting subscription");
}

Try / catch

try {
  await deleteSubscription(args);
} catch (e) {
  if (isConvexError(e) && e.data?.kind === "CUSTOMER_PROVENANCE_REQUIRED") {
    await repairCustomerFromProvider(e.data.subscriptionId); // Dodo API / audit, then retry
  }
}

Prevention

When it happens

Trigger: Processing a deletion (webhook or mutation) where sub.dodoCustomerId is missing and rawCustomerId is absent, empty, or not a string, leaving customerId as "" after trim.

Common situations: Dodo webhook payload malformed or missing customer field; subscription created before customer-ID propagation was added; test fixtures omitting dodoCustomerId; webhook replay with a stripped payload.

Understand the failure class

Background: "missing required argument" and "the following required arguments were not provided": what required-argument errors mean and how to fix them — this error's family across 20 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/da639127a62c1011. Report an issue: GitHub.

Appendix: source

Thrown at convex/payments/billing.ts:4255

        q.eq("dodoSubscriptionId", args.dodoSubscriptionId),
      )
      .unique();
    if (!sub) {
      throw new Error(
        `[billing] deleteSubscriptionByDodoId: no subscription found with dodoSubscriptionId="${args.dodoSubscriptionId}"`,
      );
    }

    const userId = sub.userId;
    const rawCustomerId = (sub.rawPayload as { customer?: { customer_id?: unknown } } | null)
      ?.customer?.customer_id;
    const customerId = sub.dodoCustomerId ||
      (typeof rawCustomerId === "string" ? rawCustomerId : "");
    // A user's current customer mapping does not prove this subscription's
    // customer: shared customer rows are reassigned by later webhooks. Preserve
    // the subscription until its customer can be repaired from provider/audit evidence.
    if (!customerId.trim()) {
      throw new ConvexError({ kind: "CUSTOMER_PROVENANCE_REQUIRED" });
    }
    if (customerId) {
      const retainedOwner = await ctx.db.query("deletedSubscriptionCustomers")
        .withIndex("by_customer_user", (q) => q.eq("dodoCustomerId", customerId).eq("userId", userId))
        .first();
      if (!retainedOwner) {
        await ctx.db.insert("deletedSubscriptionCustomers", { userId, dodoCustomerId: customerId });
      }
    }
    // Index prefix — deliberately unfiltered by cohort so deleting a
    // subscription reaps BOTH its day-0 and retro presentation rows.
    const presentations = await ctx.db
      .query("proActivationPresentations")
      .withIndex("by_subscription_cohort", (q) => q.eq("subscriptionId", sub._id))
      .collect();
    for (const presentation of presentations) {
      await ctx.db.delete(presentation._id);
    }

View on GitHub (pinned to 7d06c8633d)