Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/lib/swr/use-webhooks.ts
- apps/web/app/ee/api/stripe/webhook/route.ts
- apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts
- apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts
- apps/web/app/ee/api/stripe/integration/webhook/route.ts
- apps/web/app/api/callback/plain/workspace/route.ts
- apps/web/app/api/workspaces/idOrSlug/billing/manage/route.ts
- apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts
- apps/web/app/ee/api/stripe/integration/webhook/invoice-paid.ts
- apps/web/app/api/webhooks/route.ts
- apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts
- apps/web/app/ee/api/singular/webhook/route.ts
- apps/web/app/ee/api/stripe/connect/webhook/route.ts
- apps/web/app/api/dub/webhook/route.ts
- apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts
- apps/web/app/api/workspaces/idOrSlug/billing/invoices/route.ts
- apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts
- apps/web/app/ee/api/hubspot/webhook/route.ts
- apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx
- apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx
- apps/web/lib/dub.ts
- apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts
- apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/page.tsx
- apps/web/app/app.dub.co/dashboard/slug/ee/settings/webhooks/page-client.tsx
- packages/stripe-app/src/utils/dub.ts
- apps/web/app/api/workspaces/idOrSlug/billing/activate-paid-plan/route.ts
- apps/web/app/api/dub/webhook/lead-created.ts
- packages/stripe-app/src/hooks/use-workspace.ts
- apps/web/ui/workspaces/subscription-menu.tsx
- apps/web/lib/api/billing/recompute-workspace-usage.ts
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 Method | Request Body / Schema | Action & Behavior |
|---|---|---|
GET | None (Query params optional) | Retrieves customer payment methods, pending microdeposits, and invoice settings default payment method. |
POST | addPaymentMethodSchema (method?: string) | Creates a Stripe setup checkout session for a specific payment method or opens a billing portal session. |
PATCH | updatePaymentMethodSchema (paymentMethodId: string) | Sets the default payment method on Stripe customer invoice settings (direct debits disallowed). |
DELETE | deletePaymentMethodSchema (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 Type | Handler Function |
|---|---|
charge.succeeded | chargeSucceeded(event) |
charge.failed | chargeFailed(event) |
charge.refunded | chargeRefunded(event) |
charge.dispute.created | chargeDisputeCreated(event) |
checkout.session.completed | checkoutSessionCompleted(event) |
customer.subscription.created | customerSubscriptionCreated(event) |
customer.subscription.updated | customerSubscriptionUpdated(event) |
customer.subscription.deleted | customerSubscriptionDeleted(event) |
invoice.payment_failed | invoicePaymentFailed(event) |
payment_intent.requires_action | paymentIntentRequiresAction(event) |
transfer.reversed | transferReversed(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 Resource | Source Provider | Aggregation Logic | Target Project Field |
|---|---|---|---|
events | Tinybird (getWorkspaceUsage) | Sum of array values (d.value) | usage |
links | Tinybird (getWorkspaceUsage) | Sum of array values (d.value) | linksUsage |
partnerPayout | Prisma Invoice model | Aggregate sum where status is completed | payoutsUsage |
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 Count | Email Subject Prefix |
|---|---|
1 | Your payment for Dub.co failed |
2 | 2nd notice: Your payment for Dub.co failed |
3 | 3rd 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:
- Disables all workspace short links via
disableWorkspaceLinks. - Demotes all workspace users to the
viewerrole, excluding the constantLEGAL_USER_ID. - Upserts or creates
LEGAL_USER_IDas the workspaceowner. - Timestamps the workspace
disabledAtfield. - Registers the customer ID, customer email, and payment method card fingerprint into Stripe fraud value lists via
addToStripeFraudValueLists. - 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 Tab | Icon Component | Associated Limit Constant / Field |
|---|---|---|
events | CursorRays | usageLimit |
links | Hyperlink | linksLimit |
payouts | MoneyBills2 | payoutsLimit |
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 / Identifier | Plain Badge Color |
|---|---|
enterprise | RED |
advanced | YELLOW |
business* (starts with) | GREEN |
pro | BLUE |
| Other / Free | GREY |
Sources: apps/web/app/api/callback/plain/workspace/route.ts:157-166
Related
On This Page
OverviewWorkspace Billing EndpointsPortal Management and Plan UpgradesPayment Methods and InvoicesTrial ActivationStripe Webhook DispatchingWebhook Route and Signature VerificationEvent Demultiplexing and Handler RoutingSubscription Update ProcessingSubscription Lifecycle and Plan ChangesCheckout Session Fulfillment and LimitsPlan Upgrades and Downgrade MitigationUsage Recomputation and Quota AccountingAggregating Metrics Across Billing WindowsRecomputation Parameters and OperationsDelinquency Handling and Dispute EnforcementInvoice Payment Failure ProcessingCharge Dispute EnforcementBilling Dashboard and Plan Management UIClient-Side Billing Views and Usage MetricsSubscription Modals and Plan ManagementPlain CRM Plan SynchronizationRelated