Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const KeyCreatedSuccessDialog = ({
description: "Keyspace ID is required to view key details.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const useCreateKey = (
description: err.message || "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const CreateKeyDialog = ({
description: "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const EditCredits = ({ keyDetails, isOpen, onClose }: EditCreditsProps) =
description: "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const useDeleteKey = (onSuccess?: () => void) => {
description: errorMessage || "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const useEditCredits = (onSuccess?: () => void) => {
description: err.message || "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const useEditKeyName = (onSuccess: () => void) => {
description: errorMessage || "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const useEditRatelimits = (onSuccess?: () => void) => {
description: err.message || "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const handleKeyUpdateError = (err: TRPCClientErrorLike<TRPCErrorShape>) => {
description: errorMessage || "An unexpected error occurred. Please try again later.",
action: {
label: "Contact Support",
onClick: () => window.open("https://support.unkey.dev", "_blank"),
onClick: () => window.open("mailto:support@unkey.dev", "_blank"),
},
});
}
Expand Down
52 changes: 6 additions & 46 deletions apps/dashboard/app/(app)/settings/billing/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ type Props = {
subscription?: {
id: string;
status: Stripe.Subscription.Status;
trialUntil?: number;
cancelAt?: number;
};
currentProductId?: string;
Expand Down Expand Up @@ -88,14 +87,10 @@ const Mutations = () => {

export const Client: React.FC<Props> = (props) => {
const mutations = Mutations();
const allowUpdate =
props.subscription && ["active", "trialing"].includes(props.subscription.status);
const allowUpdate = props.subscription && props.subscription.status === "active";
const allowCancel =
props.subscription &&
["active", "trialing"].includes(props.subscription.status) &&
!props.subscription.cancelAt;
const isFreeTier =
!props.subscription || !["active", "trialing"].includes(props.subscription.status);
props.subscription && props.subscription.status === "active" && !props.subscription.cancelAt;
const isFreeTier = !props.subscription || props.subscription.status !== "active";
const selectedProductIndex = allowUpdate
Comment on lines +90 to 94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Don’t show “Free tier” for delinquent/non-active paid statuses; align “Change vs Upgrade” behavior

Current isFreeTier = status !== "active" shows the Free tier alert alongside “Payment Required” for statuses like past_due/unpaid/incomplete. Also, the product list uses “updateSubscription” whenever subscription exists, which is wrong for canceled/paused; it should create a new subscription instead.

Apply:

  • Only treat as free tier when there’s no subscription or status is canceled/paused/incomplete_expired.
  • Drive the “update vs create” branch off allowUpdate, not truthiness of subscription.
@@
-  const mutations = Mutations();
-  const allowUpdate = props.subscription && props.subscription.status === "active";
-  const allowCancel =
-    props.subscription && props.subscription.status === "active" && !props.subscription.cancelAt;
-  const isFreeTier = !props.subscription || props.subscription.status !== "active";
+  const mutations = Mutations();
+  const status = props.subscription?.status;
+  const allowUpdate = status === "active";
+  const allowCancel = status === "active" && !props.subscription?.cancelAt;
+  const isFreeTier =
+    !status || status === "canceled" || status === "paused" || status === "incomplete_expired";
@@
-                    {props.subscription ? (
+                    {allowUpdate ? (
                       <Confirm
                         title={`${i > selectedProductIndex ? "Upgrade" : "Downgrade"} to ${p.name}`}
@@
-                    ) : (
+                    ) : (
                       <Confirm
                         title={`Upgrade to ${p.name}`}

Result: no contradictory banners; canceled/paused users get a clean “Upgrade” create flow instead of a failing “Change” update flow.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/dashboard/app/(app)/settings/billing/client.tsx around lines 90 to 94,
the isFreeTier boolean and selectedProductIndex logic are incorrect: change
isFreeTier to be true only when there is no subscription OR the
subscription.status is explicitly one of "canceled", "paused", or
"incomplete_expired" (so delinquent statuses like
"past_due"/"unpaid"/"incomplete" are not treated as free tier), and change the
branch that decides whether to call update vs create to be driven by allowUpdate
(i.e., set selectedProductIndex/use updateSubscription only when allowUpdate is
true) rather than by the mere existence/truthiness of props.subscription; leave
allowUpdate/allowCancel logic as-is.

? props.products.findIndex((p) => p.id === props.currentProductId)
: -1;
Expand All @@ -107,12 +102,7 @@ export const Client: React.FC<Props> = (props) => {
activePage={{ href: "billing", text: "Billing" }}
/>
<Shell workspace={props.workspace}>
{props.subscription ? (
<SusbcriptionStatus
status={props.subscription.status}
trialUntil={props.subscription.trialUntil}
/>
) : null}
{props.subscription ? <SusbcriptionStatus status={props.subscription.status} /> : null}

<CancelAlert cancelAt={props.subscription?.cancelAt} />
{isFreeTier ? <FreeTierAlert /> : null}
Expand Down Expand Up @@ -192,14 +182,9 @@ export const Client: React.FC<Props> = (props) => {
productId: p.id,
})
}
fineprint={
props.hasPreviousSubscriptions
? "Do you need another trial? Contact support.unkey.dev"
: "After 14 days, the trial converts to a paid subscription."
}
trigger={(onClick) => (
<Button variant="outline" disabled={isSelected} onClick={onClick}>
{props.hasPreviousSubscriptions ? "Upgrade" : "Start 14 day trial"}
Upgrade
</Button>
)}
/>
Expand All @@ -213,7 +198,7 @@ export const Client: React.FC<Props> = (props) => {
<SettingCard
title="Add payment method"
border={props.subscription && allowCancel ? "top" : "both"}
description="Before starting a trial, you need to add a payment method."
description="Before upgrading, you need to add a payment method."
className="sm:w-full text-wrap w-full"
contentWidth="w-full"
>
Expand Down Expand Up @@ -318,35 +303,10 @@ const CancelAlert: React.FC<{ cancelAt?: number }> = (props) => {
};
const SusbcriptionStatus: React.FC<{
status: Stripe.Subscription.Status;
trialUntil?: number;
}> = (props) => {
switch (props.status) {
case "active":
return null;
case "trialing":
if (!props.trialUntil) {
return null;
}
return (
<SettingCard
title="Trial"
description={
<>
Your trial ends in{" "}
<span className="text-accent-12">
{ms(props.trialUntil - Date.now(), { long: true })}
</span>{" "}
on{" "}
<span className="text-accent-12">
{new Date(props.trialUntil).toLocaleDateString()}
</span>
.
</>
}
border="both"
className="border-info-7 bg-info-3"
/>
);

case "incomplete":
case "incomplete_expired":
Expand Down
1 change: 0 additions & 1 deletion apps/dashboard/app/(app)/settings/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ export default async function BillingPage() {
? {
id: subscription.id,
status: subscription.status,
trialUntil: subscription.trial_end ? subscription.trial_end * 1000 : undefined,
cancelAt: subscription.cancel_at ? subscription.cancel_at * 1000 : undefined,
}
: undefined
Expand Down
6 changes: 3 additions & 3 deletions apps/dashboard/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const POST = async (req: Request): Promise<Response> => {
try {
const sub = event.data.object as Stripe.Subscription;

if (!sub.trial_end || !sub.items?.data?.[0]?.price?.id || !sub.customer) {
if (!sub.items?.data?.[0]?.price?.id || !sub.customer) {
return new Response("OK");
}

Expand Down Expand Up @@ -151,14 +151,14 @@ async function alertSlack(
type: "section",
text: {
type: "mrkdwn",
text: `:bugeyes: New customer ${name} signed up for a two week trial`,
text: `:bugeyes: New customer ${name} signed up`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `A new trial for the ${product} tier has started at a price of ${price} by ${email} :moneybag: `,
text: `A new subscription for the ${product} tier has started at a price of ${price} by ${email} :moneybag: `,
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,6 @@ export const createSubscription = t.procedure
});
}

const hasPreviousSubscriptions = await stripe.subscriptions
.list({
customer: customer.id,
status: "all",
limit: 1,
})
.then((res) => res.data.length > 0);

const sub = await stripe.subscriptions.create({
customer: customer.id,
items: [
Expand All @@ -82,7 +74,6 @@ export const createSubscription = t.procedure
billing_cycle_anchor_config: {
day_of_month: 1,
},
trial_period_days: hasPreviousSubscriptions ? undefined : 14,

proration_behavior: "always_invoice",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@ description: How Unkey uses Stripe subscriptions
Our Stripe billing integration is designed with several important objectives:

1. **Calendar Month Alignment**

- All billing cycles align with calendar months (1st to end of month)
- Provides predictable billing dates for customers
- Simplifies usage calculations and reporting

2. **Free Tier Efficiency**

- Free tier users don't require Stripe customers or subscriptions
- Only create Stripe resources when users actively choose to upgrade
- Keeps Stripe dashboard clean and focused on paying customers

3. **Frictionless Upgrades**
- Seamless transition from free to paid
- Transparent trial process with payment method collection upfront
- Transparent upgrade process with payment method collection upfront
- Clear visibility into usage and quotas



## System Overview

Unkey's Stripe billing integration manages user subscriptions through a tiered pricing model, with support for legacy usage-based pricing. The system handles payment methods, trials, subscription management, and customer portals.
Expand All @@ -39,25 +39,28 @@ The high-level user flow is as follows:
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ │ │ │ │ │ │ │
│ Active Plan│◄────│ Start Trial│◄────│ Add Payment│◄────│ User │
│ Active Plan│◄────│ Upgrade │◄────│ Add Payment│◄────│ User │
│ │ │ │ │ Method │ │ Action │
└────────────┘ └────────────┘ └────────────┘ └────────────┘
```

## Key Components

1. **Workspace Billing Configuration**

- `stripeCustomerId`: Links workspace to Stripe customer
- `stripeSubscriptionId`: Links workspace to Stripe subscription
- `tier`: Current plan tier (free, pro, etc.)
- `quota`: Usage limits (primarily `requestsPerMonth`)

2. **Tiered Products**

- Set of products with increasing request quotas
- Clear upgrade/downgrade paths between plans
- Price points displayed with monthly pricing

3. **Stripe Integration**

- Payment setup with checkout sessions
- Subscription management
- Customer portal access
Expand All @@ -75,7 +78,8 @@ The high-level user flow is as follows:
To enable Stripe billing functionality in Unkey, you need to configure the following environment variables:

1. **`STRIPE_SECRET_KEY`**
- Your Stripe API secret key (begins with "sk_")

- Your Stripe API secret key (begins with "sk\_")
- Used for authenticating server-side API calls to Stripe
- Example: `STRIPE_SECRET_KEY=sk_test_51MpJhKLoBBjyJTsUAbcXYZ...`

Expand All @@ -89,6 +93,7 @@ The system is designed to gracefully handle missing Stripe configuration, displa
### Workspace Database Schema

The workspace schema includes:

```typescript
workspaces {
id: string
Expand All @@ -106,13 +111,6 @@ workspaces {
}
```

### Trial Management

- 14-day trial period
- Payment method collected upfront
- Trial converts to paid subscription automatically
- Trial end date clearly displayed to customers

## User Flows

### Adding a Payment Method
Expand All @@ -130,7 +128,7 @@ Before starting a subscription, users must add a payment method:

Once a payment method is available:

1. User selects a plan and clicks "Start 14 day trial" (or "Upgrade" if they've had a trial before)
1. User selects a plan and clicks "Upgrade"
2. System checks if user has a customer ID:
- If not, creates a checkout session for payment method collection first
- If yes, creates a subscription with the selected product
Expand Down Expand Up @@ -188,13 +186,15 @@ The system handles users who were on legacy usage-based pricing:
### Product Metadata

Products in Stripe contain crucial metadata:

- `quota_requests_per_month`: Maximum API requests allowed
- `quota_logs_retention_days`: How long logs are retained
- `quota_audit_logs_retention_days`: How long audit logs are retained

### Usage Calculation

Usage is calculated as:

- Combined total of valid key verifications and ratelimits
- Reset at the beginning of each calendar month
- Displayed as both raw numbers and percentage of quota
Binary file added deployment/data/metald/metald.db
Binary file not shown.
Loading