Skip to main content
doc0blade47/dubdocumentation home
Sign in

Workspace Subscriptions

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Workspace Subscriptions manages enterprise billing integration, subscription lifecycles, and plan quotas through Stripe webhooks, REST API endpoints, and Tinybird metrics tracking. It automates workspace plan upgrades, trial enforcement, payment delinquency handling, and billing portal access while synchronizing customer subscription states with the Plain CRM dashboard.

Sources: apps/web/app/ee/api/stripe/webhook/route.ts:33-104, apps/web/app/api/workspaces/idOrSlug/billing/manage/route.ts:8-35, apps/web/lib/api/billing/recompute-workspace-usage.ts:8-50

Workspace Billing Endpoints

Portal Management and Plan Upgrades

Dub exposes workspace-authenticated REST API routes under /api/workspaces/[idOrSlug]/billing/ to manage subscriptions, payment methods, invoices, and billing portal sessions via Stripe. These endpoints require appropriate permissions such as billing.write or workspaces.read through the withWorkspace middleware wrapper.

The billing management and upgrade routes interface directly with Stripe sessions. The portal management endpoint creates a Stripe billing portal session restricted by configuration bpc_1UEvGVAlJJEpqkPVpP38ZUNj for updating billing details, redirecting users back to ${APP_DOMAIN}/${workspace.slug}/settings/billing. Enterprise workspaces are restricted from this endpoint, throwing a forbidden DubApiError because their billing is handled by the account team.

if (workspace.plan === "enterprise") {
  throw new DubApiError({
    code: "forbidden",
    message:
      "Since you're on an Enterprise plan, billing is managed by your account team. Please reach out to them to manage your subscription.",
  });
}
 
try {
  const { url } = await stripe.billingPortal.sessions.create({
    customer: workspace.stripeId,
    configuration: "bpc_1UEvGVAlJJEpqkPVpP38ZUNj", // custom configuration for changing billing info only
    return_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,
  });

Sources: apps/web/app/api/workspaces/idOrSlug/billing/manage/route.ts:14-27

The upgrade route parses requests against upgradePlanSchema, accepting plan (pro, business, advanced), period (monthly, yearly), optional tier (1 to 3), an onboarding boolean flag, and a baseUrl validated to start with APP_DOMAIN. If a workspace has an active trialing subscription, upgrades update the subscription directly with proration_behavior: "none" without issuing invoices or immediate charges. For active non-trialing subscriptions, the endpoint generates a Stripe billing portal session configured with subscription_update_confirm. If no subscription exists, it initiates a Stripe checkout session with automatic tax and tax ID collection enabled, setting trial periods via DUB_TRIAL_PERIOD_DAYS when eligible.

const upgradePlanSchema = z.object({
  plan: z.enum(["pro", "business", "advanced"]),
  period: z.enum(["monthly", "yearly"]),
  tier: z.number().min(1).max(3).optional().default(1),
  baseUrl: z.string().refine((url) => url.startsWith(APP_DOMAIN), {
    message: "Invalid baseUrl.",
  }),
  onboarding: booleanQuerySchema.nullish(),
});

Sources: apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:12-20, apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:68-84, apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:103-123, apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:141-191

Warning

Preview environments running on Vercel (VERCEL === "1" and VERCEL_ENV === "preview") enforce an administrative role check on plan upgrades; unauthenticated requests or users without a Dub admin role receive an unauthorized error.

Sources: apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:57-65

Payment Methods and Invoices

Payment methods are retrieved, added, updated, and removed via /api/workspaces/[idOrSlug]/billing/payment-methods. The GET handler merges Stripe payment methods with pending microdeposits and reorders direct debit methods to the front of the list. The POST handler supports adding custom payment types or returning a billing portal session for payment method updates.

const [paymentMethods, customer] = await Promise.all([
  stripe.paymentMethods.list({
    customer: workspace.stripeId,
  }),
  stripe.customers.retrieve(workspace.stripeId),
]);
 
const pendingMicrodeposits = await listPendingMicrodeposits(
  workspace.stripeId,
).catch(() => []);
 
const defaultPaymentMethod =
  customer.deleted !== true
    ? customer.invoice_settings?.default_payment_method
    : null;
 
const defaultPaymentMethodId =
  typeof defaultPaymentMethod === "string"
    ? defaultPaymentMethod
    : defaultPaymentMethod?.id ?? null;
 
const methods = withMicrodepositStatus({
  paymentMethods: paymentMethods.data,
  pendingMicrodeposits,
});
 
// reorder to put direct debit first
const directDebit = methods.find((method) =>
  DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(method.type),
);
 
return NextResponse.json({
  paymentMethods: [
    ...(directDebit ? [directDebit] : []),
    ...methods.filter((method) => method.id !== directDebit?.id),
  ],
  defaultPaymentMethodId,
});

Sources: apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts:62-99

HTTP MethodRequest Body / SchemaAction & Behavior
GETNone (Query params optional)Retrieves customer payment methods, pending microdeposits, and invoice settings default payment method.
POSTaddPaymentMethodSchema (method?: string)Creates a Stripe setup checkout session for a specific payment method or opens a billing portal session.
PATCHupdatePaymentMethodSchema (paymentMethodId: string)Sets the default payment method on Stripe customer invoice settings (direct debits disallowed).
DELETEdeletePaymentMethodSchema (paymentMethodId: string)Detaches and removes the specified payment method from the Stripe customer.

Sources: apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts:20-30, apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts:51-236

Invoice listing is handled by /api/workspaces/[idOrSlug]/billing/invoices, which accepts query parameters validated against querySchema. It supports retrieving Stripe subscription invoices (type: "subscription") limited to 100 entries, as well as internal Prisma-stored invoices for partner payouts (partnerPayout) and domain renewals (domainRenewal).

const querySchema = z.object({
  type: z
    .enum(["subscription", "partnerPayout", "domainRenewal"])
    .optional()
    .default("subscription"),
  stripeStatus: z.enum(["open"]).optional(), // only for Stripe subscription invoices
});

Sources: apps/web/app/api/workspaces/idOrSlug/billing/invoices/route.ts:9-15, apps/web/app/api/workspaces/idOrSlug/billing/invoices/route.ts:24-34

Trial Activation

The /api/workspaces/[idOrSlug]/billing/activate-paid-plan endpoint immediately terminates any active trial for the workspace by setting trial_end to "now" and cancel_at_period_end to false on the Stripe subscription, triggering an immediate charge and transitioning the subscription to active status.

const { data: trialingSubs } = await stripe.subscriptions.list({
  customer: workspace.stripeId,
  status: "trialing",
  limit: 1,
});
const trialingSubscription = trialingSubs[0];
 
if (trialingSubscription) {
  await stripe.subscriptions.update(trialingSubscription.id, {
    trial_end: "now",
    cancel_at_period_end: false,
  });
  return NextResponse.json({ ok: true });
}

Sources: apps/web/app/api/workspaces/idOrSlug/billing/activate-paid-plan/route.ts:13-26

Stripe Webhook Dispatching

Webhook Route and Signature Verification

Incoming Stripe webhooks are received at the POST /api/stripe/webhook route wrapped with withAxiom. The handler reads the raw request body as text and extracts the Stripe-Signature header alongside the STRIPE_WEBHOOK_SECRET environment variable. If either the signature or the secret is missing, it responds immediately with an Invalid request status of 400 via logAndRespond. Otherwise, stripe.webhooks.constructEvent verifies the cryptographic payload signature, returning a typed Stripe.Event object or catching parsing errors to log and return a 400 response.

export const POST = withAxiom(async (req: Request) => {
  const buf = await req.text();
  const sig = req.headers.get("Stripe-Signature") as string;
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  let event: Stripe.Event;
  try {
    if (!sig || !webhookSecret) {
      return logAndRespond("Invalid request", { status: 400 });
    }
    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
  } catch (err: any) {
    console.log(`❌ Error message: ${err.message}`);
    return new Response(`Webhook Error: ${err.message}`, {
      status: 400,
    });
  }

Sources: apps/web/app/ee/api/stripe/webhook/route.ts:33-48

Warning

Requests lacking a valid Stripe-Signature header or evaluated against an unconfigured STRIPE_WEBHOOK_SECRET are short-circuited with a 400 error before any event parsing occurs.

Sources: apps/web/app/ee/api/stripe/webhook/route.ts:39-41

Event Demultiplexing and Handler Routing

Once constructed, the event type is checked against a predefined relevantEvents set. Events not present in this set are ignored, returning a 200 status response with Unsupported event, skipping.... Relevant events pass through a switch statement that routes each payload to its dedicated handler function. Any unhandled execution error within a handler is caught, logged to Axiom with log, and returned as a 400 webhook error response.

Event TypeHandler Function
charge.succeededchargeSucceeded(event)
charge.failedchargeFailed(event)
charge.refundedchargeRefunded(event)
charge.dispute.createdchargeDisputeCreated(event)
checkout.session.completedcheckoutSessionCompleted(event)
customer.subscription.createdcustomerSubscriptionCreated(event)
customer.subscription.updatedcustomerSubscriptionUpdated(event)
customer.subscription.deletedcustomerSubscriptionDeleted(event)
invoice.payment_failedinvoicePaymentFailed(event)
payment_intent.requires_actionpaymentIntentRequiresAction(event)
transfer.reversedtransferReversed(event)

Sources: apps/web/app/ee/api/stripe/webhook/route.ts:18-30, apps/web/app/ee/api/stripe/webhook/route.ts:50-93

Subscription Update Processing

The customerSubscriptionUpdated function processes updates for subscriptions with status values of "active", "trialing", or "past_due". Any other subscription status causes the handler to skip processing and return an invalid status message. Workspaces are queried via Prisma using the subscription's customer string mapped to stripeId, selecting workspace owner users (excluding machine users) and restricted tokens.

export async function customerSubscriptionUpdated(
  event: Stripe.CustomerSubscriptionUpdatedEvent,
) {
  const updatedSubscription = event.data.object;
  const priceId = updatedSubscription.items.data[0].price.id;
 
  if (
    ![
      "active",
      "trialing",
      "past_due", // special case for handling past_due subscriptions (after a free trial)
    ].includes(updatedSubscription.status)
  ) {
    return `Invalid updated subscription status "${updatedSubscription.status}" for subscription ${updatedSubscription.id}, skipping...`;
  }
 
  const stripeId = updatedSubscription.customer.toString();
 
  const workspace = await prisma.project.findUnique({
    where: {
      stripeId,
    },
    include: {
      users: {
        select: {
          user: {
            select: {
              email: true,
              name: true,
            },
          },
        },
        where: {
          role: "owner",
          user: {
            isMachine: false,
          },
        },
      },
      restrictedTokens: {
        select: {
          hashedKey: true,
        },
      },
    },
  });

Sources: apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts:8-53

Note

When a subscription enters the past_due state, the handler checks if the workspace trial ended less than 2 hours ago. If so, it reverts the workspace plan limits back to TRIAL_LIMITS values across all tracked resource quotas.

Sources: apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts:59-89

If the subscription status is active or trialing, updateWorkspacePlan is invoked with the workspace, price ID, and subscription object. If cancel_at_period_end is enabled, owner details and cancellation feedback are extracted to dispatch cancellation feedback notifications.

await updateWorkspacePlan({
  workspace,
  priceId,
  subscription: updatedSubscription,
});
 
if (updatedSubscription.cancel_at_period_end) {
  const owners = workspace.users.map(({ user }) => user);
  const cancelReason = updatedSubscription.cancellation_details?.feedback;
 
  await sendCancellationFeedback({
    workspace,
    owners,
    reason: cancelReason,
  });
  return `Sent cancellation feedback to ${owners.length} workspace owners for workspace ${workspace.slug}.`;
}

Sources: apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts:93-109

Subscription Lifecycle and Plan Changes

Checkout Session Fulfillment and Limits

When a Stripe checkout session completes successfully, checkoutSessionCompleted validates that the session mode is set to "subscription" and that the payment status is either "paid" or "no_payment_required". Setup sessions or uncompleted payments are skipped. The system retrieves the associated Stripe subscription, extracts the price ID, and maps it to a plan and tier using getPlanAndTierFromPriceId. If client_reference_id or customer is missing, an error is logged and processing terminates.

if (checkoutSession.mode === "setup") {
  return "Session is setup mode, skipping...";
}
 
if (checkoutSession.mode === "subscription") {
  if (
    checkoutSession.payment_status !== "paid" &&
    checkoutSession.payment_status !== "no_payment_required"
  ) {
    return "Subscription checkout session not completed (payment status), skipping...";
  }
} else {
  return `Session mode ${checkoutSession.mode} is not handled here, skipping...`;
}
 
if (
  checkoutSession.client_reference_id === null ||
  checkoutSession.customer === null
) {
  await log({
    message: "Missing items in Stripe webhook callback",
    type: "errors",
  });
  return "Missing client_reference_id or customer in checkout session.";
}

Sources: apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:24-48

Workspace limits are calculated through getWorkspaceLimitsFromStripeSubscription. When a subscription is in a trial state, restricted trial quotas from TRIAL_LIMITS are applied across all tracked metrics instead of standard plan limits. For active non-trial yearly subscriptions, limits for clicks, links, payouts, and AI capabilities are multiplied by 12 to reflect the annual billing period allocation.

export function getWorkspaceLimitsFromStripeSubscription({
  planLimits,
  subscription,
}: {
  planLimits: PlanDetails["limits"];
  subscription: Stripe.Subscription;
}): PlanDetails["limits"] {
  if (subscription.status !== "trialing") {
    const subscriptionPeriod =
      getPlanPeriodFromStripeSubscription(subscription);
    return {
      ...planLimits,
      ...(subscriptionPeriod === "yearly"
        ? {
            clicks: planLimits.clicks * 12,
            links: planLimits.links * 12,
            payouts: planLimits.payouts * 12,
            ai: planLimits.ai * 12,
          }
        : {}),
    };
  }
 
  return {
    ...planLimits,
    links: TRIAL_LIMITS.links,
    clicks: TRIAL_LIMITS.clicks,
    payouts: TRIAL_LIMITS.payouts,
    users: TRIAL_LIMITS.users,
    domains: TRIAL_LIMITS.domains,
    tags: TRIAL_LIMITS.tags,
    partnerTags: TRIAL_LIMITS.partnerTags,
    folders: TRIAL_LIMITS.folders,
    groups: TRIAL_LIMITS.groups,
    networkInvites: TRIAL_LIMITS.networkInvites,
    partners: TRIAL_LIMITS.partners,
    ai: TRIAL_LIMITS.ai,
    api: TRIAL_LIMITS.api,
    analyticsApi: TRIAL_LIMITS.analyticsApi,
  };

Sources: apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts:5-44

Note

Trial limits strictly override standard plan limits regardless of the base plan tier selected during checkout, enforcing a uniform constraint on features like links, clicks, and API access until the trial period expires and converts to a paid subscription.

Sources: apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts:28-44

Plan Upgrades and Downgrade Mitigation

The updateWorkspacePlan utility manages transitions when a workspace modifies its subscription plan, tier, or billing period. If a workspace shifts from a yearly to a monthly billing period, recomputeWorkspaceUsage recalculates current usage metrics against the reduced limits.

const isYearlyToMonthly =
  workspace.planPeriod === "yearly" && planPeriod === "monthly";
 
const recomputedUsage = isYearlyToMonthly
  ? await recomputeWorkspaceUsage(workspace)
  : null;
 

Sources: apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:108-114

Sources: apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:24-74, apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:138-174, apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts:5-44

Usage Recomputation and Quota Accounting

Aggregating Metrics Across Billing Windows

When a workspace transitions from a yearly to a monthly plan period, usage metrics are explicitly recomputed via recomputeWorkspaceUsage. This function determines the current billing start date using getBillingStartDate based on the workspace's billingCycleStart field, setting the end date to the current timestamp.

export async function recomputeWorkspaceUsage(
  workspace: Pick<Project, "id" | "billingCycleStart">,
) {
  const billingStart = getBillingStartDate(workspace.billingCycleStart);
  const billingEnd = new Date();
 

Sources: apps/web/lib/api/billing/recompute-workspace-usage.ts:8-13

Three concurrent data aggregation queries execute to assemble the updated usage state: click events and link counts fetched from Tinybird via getWorkspaceUsage, alongside completed partner payout invoice amounts aggregated from Prisma.

const [clicksData, linksData, payoutsUsage] = await Promise.all([
  getWorkspaceUsage({
    workspaceId: workspace.id,
    resource: "events",
    start: billingStart,
    end: billingEnd,
  }),
 
  getWorkspaceUsage({
    workspaceId: workspace.id,
    resource: "links",
    start: billingStart,
    end: billingEnd,
  }),
 
  prisma.invoice.aggregate({
    where: {
      workspaceId: workspace.id,
      type: "partnerPayout",
      status: "completed",
      paidAt: {
        gte: billingStart,
        lte: billingEnd,
      },
    },
    _sum: {
      amount: true,
    },
  }),
]);

Sources: apps/web/lib/api/billing/recompute-workspace-usage.ts:14-43

Recomputation Parameters and Operations

The recomputation routine processes event and resource streams across specific parameters before mapping them to workspace project columns.

Metric ResourceSource ProviderAggregation LogicTarget Project Field
eventsTinybird (getWorkspaceUsage)Sum of array values (d.value)usage
linksTinybird (getWorkspaceUsage)Sum of array values (d.value)linksUsage
partnerPayoutPrisma Invoice modelAggregate sum where status is completedpayoutsUsage

Sources: apps/web/lib/api/billing/recompute-workspace-usage.ts:14-49, apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:154-157

Note

When recomputedUsage is generated during a yearly-to-monthly downgrade, the update payload also deletes existing usage limit notification emails (firstUsageLimitEmail, secondUsageLimitEmail, firstLinksLimitEmail, and secondLinksLimitEmail) from the sentEmails relation to reset warning thresholds for the new billing cycle.

Sources: apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:154-170

Delinquency Handling and Dispute Enforcement

Invoice Payment Failure Processing

When Stripe fires an invoice.payment_failed event, the system extracts the customer ID, attempt count, and amount due. It queries the project model in Prisma using the Stripe customer ID to identify the workspace and filters workspace users to exclude machine accounts where isMachine is true.

export async function invoicePaymentFailed(
  event: Stripe.InvoicePaymentFailedEvent,
) {
  const {
    customer: stripeId,
    attempt_count: attemptCount,
    amount_due: amountDue,
  } = event.data.object;
 
  if (!stripeId) {
    return "No customer found in invoice.payment_failed event.";
  }
 
  const workspace = await prisma.project.findUnique({
    where: {
      stripeId: stripeId.toString(),
    },
    select: {
      id: true,
      name: true,
      slug: true,
      plan: true,
      defaultProgramId: true,
      users: {
        select: {
          user: {
            select: {
              name: true,
              email: true,
            },
          },
        },
        where: {
          user: {
            isMachine: false,
          },
        },
      },
    },
  });

Sources: apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx:6-45

The workspace record receives a timestamp update on its paymentFailedAt field, while notification emails are dispatched concurrently using Promise.allSettled to all eligible human users. The email subject dynamically adjusts based on the payment attemptCount value.

Attempt CountEmail Subject Prefix
1Your payment for Dub.co failed
22nd notice: Your payment for Dub.co failed
33rd notice: Your payment for Dub.co failed
4+ (default)Final notice: Your payment for Dub.co failed

Sources: apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx:51-86

Warning

invoicePaymentFailed uses Promise.allSettled when updating the project timestamp and dispatching notification emails. If individual email deliveries fail for specific users, the workspace payment failure timestamp update still resolves successfully.

Sources: apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx:51-86

Charge Dispute Enforcement

When a charge dispute is created via charge_dispute.created, the system retrieves the associated Stripe charge and expands its customer object to locate the corresponding workspace by stripeId.

export async function chargeDisputeCreated(
  event: Stripe.ChargeDisputeCreatedEvent,
) {
  const dispute = event.data.object;
  const chargeId =
    typeof dispute.charge === "string" ? dispute.charge : dispute.charge.id;
 
  const charge = await stripe.charges.retrieve(chargeId, {
    expand: ["customer"],
  });
 
  const { customer: customerObj, payment_method_details } = charge;
  const customer = customerObj as Stripe.Customer;
 
  const workspace = await prisma.project.findUnique({
    where: {
      stripeId: customer.id,
    },
  });
 
  // should never happen, but just in case
  if (!workspace) {
    return `Workspace with stripeId ${customer.id} not found.`;
  }

Sources: apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:9-32

Upon locating the workspace, the system executes a sequence of security and restriction measures:

  1. Disables all workspace short links via disableWorkspaceLinks.
  2. Demotes all workspace users to the viewer role, excluding the constant LEGAL_USER_ID.
  3. Upserts or creates LEGAL_USER_ID as the workspace owner.
  4. Timestamps the workspace disabledAt field.
  5. Registers the customer ID, customer email, and payment method card fingerprint into Stripe fraud value lists via addToStripeFraudValueLists.
  6. Cancels the active Stripe subscription with the reason "Workspace banned due to charge dispute".
await disableWorkspaceLinks(workspace.id);
 
// Update all users to viewer role except for LEGAL_USER_ID
const updatedUsers = await prisma.projectUsers.updateMany({
  where: {
    projectId: workspace.id,
    userId: {
      not: LEGAL_USER_ID,
    },
  },
  data: {
    role: "viewer",
  },
});
 
console.log(`Updated ${updatedUsers.count} users to viewer role`);
 
// Add LEGAL_USER_ID as owner
await prisma.projectUsers.upsert({
  where: {
    userId_projectId: {
      projectId: workspace.id,
      userId: LEGAL_USER_ID,
    },
  },
  update: {
    role: "owner",
  },
  create: {
    projectId: workspace.id,
    userId: LEGAL_USER_ID,
    role: "owner",
  },
});
 
console.log(
  `Added legal user ${LEGAL_USER_ID} as owner to workspace ${workspace.id}`,
);
 
await prisma.project.update({
  where: {
    id: workspace.id,
  },
  data: {
    disabledAt: new Date(),
  },
});
 
await addToStripeFraudValueLists({
  customerId: customer.id,
  customerEmail: customer.email,
  cardFingerprint: payment_method_details?.card?.fingerprint,
});
 
await cancelSubscription({
  customerId: customer.id,
  reason: "Workspace banned due to charge dispute",
});
 

Sources: apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:34-92

Caution

A charge dispute event immediately revokes owner privileges from all standard workspace members by forcing their project roles to viewer, elevates LEGAL_USER_ID to sole owner, disables all links, and terminates the underlying subscription.

Sources: apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:34-92

Billing Dashboard and Plan Management UI

Client-Side Billing Views and Usage Metrics

The workspace billing interface renders detailed plan usage PlanUsage, consumption totals, and active subscription status WorkspaceBilling. The PlanUsage component retrieves workspace metadata from useWorkspace() to evaluate real-time usage metrics across tracked events, links created, and payouts sent.

export default function PlanUsage() {
  const {
    id: workspaceId,
    slug,
    plan,
    role,
    stripeId,
    usage,
    usageLimit,
    linksUsage,
    linksLimit,
    totalLinks,
    payoutsUsage,
    payoutsLimit,
    payoutFee,
    payoutFeeWaiverLimit,
    payoutFeeWaiverUsage,
    domains,
    domainsLimit,
    foldersUsage,
    foldersLimit,
    partnersUsage,
    partnersLimit,
    groupsLimit,
    tagsLimit,
    usersLimit,
    billingCycleStart,
    planPeriod,
    trialEndsAt,
    subscriptionCanceledAt,
    billingCycleEndsAt,
    defaultProgramId,
    loading,
    mutate,
  } = useWorkspace();

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx:56-90

The interface dynamically builds resource utilization tabs using useMemo, conditionally prepending the links tab if total usage exceeds MEGA_WORKSPACE_LINKS_LIMIT or appending the payouts tab when a default program identifier is present.

Resource TabIcon ComponentAssociated Limit Constant / Field
eventsCursorRaysusageLimit
linksHyperlinklinksLimit
payoutsMoneyBills2payoutsLimit

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx:132-179

Note

The payouts resource tab is reserved during initial loading states to prevent layout shifts while asynchronous SWR hooks resolve workspace properties.

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx:157-167

Subscription Modals and Plan Management

The SubscriptionMenu component exposes administrative controls for enterprise and non-enterprise workspaces SubscriptionMenu. Non-enterprise workspaces render a popover menu containing actions to open the Stripe billing portal or cancel the active subscription.

export default function SubscriptionMenu() {
  const {
    id: workspaceId,
    role,
    plan,
    defaultProgramId,
    billingCycleEndsAt,
    subscriptionCanceledAt,
    mutate,
  } = useWorkspace();
  const router = useRouter();
 
  const permissionsError = clientAccessCheck({
    action: "billing.write",
    role,
  }).error;
 
  const [isOpen, setIsOpen] = useState(false);
  const [clicked, setClicked] = useState(false);
 
  const openBillingPortal = async () => {
    setIsOpen(false);
    setClicked(true);
    return fetch(`/api/workspaces/${workspaceId}/billing/manage`, {
      method: "POST",
    })
      .then(async (res) => {
        if (res.ok) {
          const { url } = await res.json();
          router.push(url);
        } else {
          const { error } = await res.json();
          toast.error(error.message);
        }
      })
      .finally(() => {
        setClicked(false);
      });
  };

Sources: apps/web/ui/workspaces/subscription-menu.tsx:24-62

When canceling a subscription, the client verifies whether the workspace would lose partner access by evaluating wouldLosePartnerAccess. If partner access is affected, selecting cancellation triggers a plan change confirmation modal before opening the cancellation dialog.

// Check if canceling would lose partner access
const losesPartnerAccess =
  plan &&
  defaultProgramId &&
  wouldLosePartnerAccess({ currentPlan: plan, newPlan: null });
 
const { setShowCancelSubscriptionModal, CancelSubscriptionModal } =
  useCancelSubscriptionModal({
    billingCycleEndsAt,
    onConfirm: handleCancellation,
  });
 
const { setShowPlanChangeConfirmationModal, PlanChangeConfirmationModal } =
  usePlanChangeConfirmationModal({
    newPlan: "free",
    newPeriod: "monthly",
    newTier: 1,
    onConfirm: async () => {
      setShowPlanChangeConfirmationModal(false);
      setShowCancelSubscriptionModal(true);
    },
  });
 
const handleCancelSubscription = () => {
  setIsOpen(false);
  if (losesPartnerAccess) {
    setShowPlanChangeConfirmationModal(true);
  } else {
    setShowCancelSubscriptionModal(true);
  }
};

Sources: apps/web/ui/workspaces/subscription-menu.tsx:97-127

Plain CRM Plan Synchronization

Customer support workflows integrate with Plain CRM via webhook callbacks that authenticate requests using the X-Plain-Webhook-Secret header POST.

export async function POST(req: NextRequest) {
  // authenticate webhook X-Plain-Webhook-Secret
  const token = req.headers.get("X-Plain-Webhook-Secret");
  if (token !== process.env.PLAIN_WEBHOOK_SECRET) {
    return new Response("Unauthorized", { status: 401 });
  }

Sources: apps/web/app/api/callback/plain/workspace/route.ts:20-25

Upon receiving a valid callback for a customer, the system resolves the associated user, verifies email blacklist status, upserts customer metadata, and queries the primary workspace ordered by usage limits POST.

let { customer } = plainCallbackSchema.parse(await req.json());
 
const user = await prisma.user.findUnique({
  where: customer.externalId
    ? { id: customer.externalId }
    : { email: customer.email },
});
 
if (!user || !user.email) {
  const isBannedUser = await isBlacklistedEmail(customer.email);
  if (isBannedUser) {
    await plain.addCustomerToCustomerGroups({
      customerId: customer.id,
      customerGroupIdentifiers: [
        {
          customerGroupKey: "banned_users",
        },
      ],
    });
  }
 
  return NextResponse.json({
    cards: [
      {
        key: "workspace",
        components: [plainEmptyContainer("No user found.")],
      },
    ],
  });
}
 
if (!customer.externalId) {
  customer.externalId = user.id;
  await upsertPlainCustomer({
    id: user.id,
    name: user.name,
    email: user.email,
  });
}
 
waitUntil(syncUserPlanToPlain(user));
 
const topWorkspace = await prisma.project.findFirst({
  where: {
    users: {
      some: {
        userId: customer.externalId,
      },
    },
  },
  include: {
    _count: {
      select: {
        domains: true,
        users: true,
      },
    },
  },
  orderBy: {
    usageLimit: "desc",
  },
  take: 1,
});

Sources: apps/web/app/api/callback/plain/workspace/route.ts:27-89

The endpoint returns dynamic Plain CRM card components that format plan badges with specific colors corresponding to the workspace subscription tier.

Plan Tier / IdentifierPlain Badge Color
enterpriseRED
advancedYELLOW
business* (starts with)GREEN
proBLUE
Other / FreeGREY

Sources: apps/web/app/api/callback/plain/workspace/route.ts:157-166

Was this page helpful?