Skip to content
Draft
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
@@ -1,6 +1,6 @@
import { Routes } from '@/utils/constants';
import { legacySubscriptions, prices, subscriptions, toSubscription, users } from '@onlook/db';
import { createBillingPortalSession, createCheckoutSession, createCustomer, isTierUpgrade, PriceKey, releaseSubscriptionSchedule, SubscriptionStatus, updateSubscription, updateSubscriptionNextPeriod } from '@onlook/stripe';
import { createBillingPortalSession, createCheckoutSession, createCustomer, isTierUpgrade, PriceKey, releaseSubscriptionSchedule, SubscriptionStatus, updateSubscriptionNextPeriod, upgradeSubscription } from '@onlook/stripe';
import { and, eq, isNull } from 'drizzle-orm';
import { headers } from 'next/headers';
import { z } from 'zod';
Expand Down Expand Up @@ -162,7 +162,7 @@ export const subscriptionRouter = createTRPCRouter({
const isUpgrade = isTierUpgrade(currentPrice, newPrice);
if (isUpgrade) {
// If the new price is higher, we invoice the customer immediately.
await updateSubscription({
await upgradeSubscription({
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
priceId: stripePriceId,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/template/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@ const nextConfig = {
devIndicators: {
buildActivity: false,
},
eslint: {
Copy link
Contributor

Choose a reason for hiding this comment

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

Avoid ignoring ESLint/TypeScript build errors unless absolutely necessary. This may hide real issues in production.

ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
};
export default nextConfig;
16 changes: 0 additions & 16 deletions apps/web/template/next.config.ts

This file was deleted.

78 changes: 78 additions & 0 deletions packages/stripe/src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,84 @@ export const updateSubscription = async ({
});
};

export const upgradeSubscription = async ({
subscriptionId,
subscriptionItemId,
priceId,
}: {
subscriptionId: string;
subscriptionItemId: string;
priceId: string;
}) => {
const stripe = createStripeClient();
const currentSubscription = await stripe.subscriptions.retrieve(subscriptionId);
if (!currentSubscription) {
throw new Error('Subscription not found');
}

const currentItem = currentSubscription.items.data.find((item) => item.id === subscriptionItemId);
if (!currentItem) {
throw new Error('Subscription item not found');
}

const currentPrice = currentItem.price.id;
if (currentPrice === priceId) {
throw new Error('New price is the same as the current price');
}

const currentPriceAmount = currentItem.price.unit_amount;
if (currentPriceAmount == null) {
throw new Error('Current price amount not found');
}

const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscriptionItemId,
price: priceId,
},
],
// We don't want to prorate the price difference because it would be based on time remaining in the current period
proration_behavior: 'none',
});

const newItem =
updatedSubscription.items.data.find((i) => i.id === subscriptionItemId)
?? updatedSubscription.items.data[0];
Copy link
Contributor

Choose a reason for hiding this comment

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

Falling back to the first item if the matching subscription item isn't found may mask issues. It might be safer to throw an error instead.

Suggested change
?? updatedSubscription.items.data[0];
?? (() => { throw new Error('Subscription item not found on updated subscription'); })()

if (!newItem) {
throw new Error('Subscription item not found on updated subscription');
}
const newPriceAmount = newItem.price?.unit_amount;
if (newPriceAmount == null) {
throw new Error('New price amount not found');
}

const quantity = newItem.quantity ?? 1;
const priceDifferenceAmount = (newPriceAmount - currentPriceAmount) * quantity;

// Create a one-off invoice item for the price difference if the new price is higher
if (priceDifferenceAmount > 0) {
await stripe.invoiceItems.create({
customer: updatedSubscription.customer as string,
amount: priceDifferenceAmount,
Copy link
Contributor

Choose a reason for hiding this comment

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

The currency field was removed from the invoiceItems.create call. Stripe typically requires a currency for invoice items; consider reinstating it.

Suggested change
amount: priceDifferenceAmount,
currency: 'usd',

description: 'Onlook subscription upgrade',
});
Comment on lines +169 to +173
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Missing currency on invoice item will cause a Stripe error

When creating invoice items with an explicit amount, Stripe requires a currency. If you don’t adopt the larger refactor above, minimally include the currency from the item’s price.

Apply this minimal diff:

         await stripe.invoiceItems.create({
             customer: updatedSubscription.customer as string,
             amount: priceDifferenceAmount,
+            currency: newItem.price?.currency || currentItem.price?.currency || 'usd',
             description: 'Onlook subscription upgrade',
         });

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

🤖 Prompt for AI Agents
In packages/stripe/src/functions.ts around lines 169 to 173, the
invoiceItems.create call is missing the required currency when supplying an
explicit amount; add a currency property using the currency from the relevant
Price object (for example currency: item.price.currency or currency:
price.currency — whichever variable you used to compute priceDifferenceAmount),
or extract it from updatedSubscription items if needed, and pass that currency
into the invoiceItems.create payload.


// Create invoice immediately
const invoice = await stripe.invoices.create({
customer: updatedSubscription.customer as string,
auto_advance: true,
});

if (!invoice.id) {
throw new Error('Invoice not created');
}
await stripe.invoices.pay(invoice.id);
}
Comment on lines +142 to +185
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Prevent free upgrades on payment failure: charge first (with currency), then apply the subscription update

Currently the subscription is upgraded (Line 142) before charging the upgrade delta. If invoice creation or payment fails, the customer can receive a free upgrade. Also, the invoice item is created without a currency, which Stripe requires when specifying an amount.

Refactor to:

  • Compute the delta using the new Price object (no update yet).
  • Validate currency and include it when creating the invoice item.
  • Create and pay the invoice first; only then update the subscription.
  • Keep proration_behavior: 'none' as intended.

Apply this diff to the function body to fix ordering and currency handling:

-    const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
-        items: [
-            {
-                id: subscriptionItemId,
-                price: priceId,
-            },
-        ],
-        // We don't want to prorate the price difference because it would be based on time remaining in the current period
-        proration_behavior: 'none',
-    });
-
-    const newItem =
-        updatedSubscription.items.data.find((i) => i.id === subscriptionItemId)
-        ?? updatedSubscription.items.data[0];
-    if (!newItem) {
-        throw new Error('Subscription item not found on updated subscription');
-    }
-    const newPriceAmount = newItem.price?.unit_amount;
-    if (newPriceAmount == null) {
-        throw new Error('New price amount not found');
-    }
-
-    const quantity = newItem.quantity ?? 1;
-    const priceDifferenceAmount = (newPriceAmount - currentPriceAmount) * quantity;
-
-    // Create a one-off invoice item for the price difference if the new price is higher
-    if (priceDifferenceAmount > 0) {
-        await stripe.invoiceItems.create({
-            customer: updatedSubscription.customer as string,
-            amount: priceDifferenceAmount,
-            description: 'Onlook subscription upgrade',
-        });
-
-        // Create invoice immediately
-        const invoice = await stripe.invoices.create({
-            customer: updatedSubscription.customer as string,
-            auto_advance: true,
-        });
-
-        if (!invoice.id) {
-            throw new Error('Invoice not created');
-        }
-        await stripe.invoices.pay(invoice.id);
-    }
-
-    return updatedSubscription;
+    // Compute delta using the target Price (no subscription change yet)
+    const newPrice = await stripe.prices.retrieve(priceId);
+    const newPriceAmount = newPrice.unit_amount;
+    if (newPriceAmount == null) {
+        throw new Error('New price amount not found');
+    }
+
+    const quantity = currentItem.quantity ?? 1;
+    const currentCurrency = currentItem.price.currency;
+    const newCurrency = newPrice.currency;
+    if (currentCurrency && newCurrency && currentCurrency !== newCurrency) {
+        throw new Error('Cannot upgrade across different currencies');
+    }
+    const currency = newCurrency ?? currentCurrency;
+    if (!currency) {
+        throw new Error('Currency not found on price');
+    }
+
+    const priceDifferenceAmount = (newPriceAmount - currentPriceAmount) * quantity;
+
+    // Charge the delta first (if positive)
+    if (priceDifferenceAmount > 0) {
+        await stripe.invoiceItems.create({
+            customer: currentSubscription.customer as string,
+            amount: priceDifferenceAmount,
+            currency,
+            description: 'Onlook subscription upgrade',
+        });
+
+        const invoice = await stripe.invoices.create({
+            customer: currentSubscription.customer as string,
+            auto_advance: true,
+            collection_method: 'charge_automatically',
+        });
+        if (!invoice.id) {
+            throw new Error('Invoice not created');
+        }
+        await stripe.invoices.pay(invoice.id);
+    }
+
+    // Only after successful payment do we apply the subscription update without proration
+    const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
+        items: [
+            {
+                id: subscriptionItemId,
+                price: priceId,
+            },
+        ],
+        proration_behavior: 'none',
+    });
+
+    return updatedSubscription;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscriptionItemId,
price: priceId,
},
],
// We don't want to prorate the price difference because it would be based on time remaining in the current period
proration_behavior: 'none',
});
const newItem =
updatedSubscription.items.data.find((i) => i.id === subscriptionItemId)
?? updatedSubscription.items.data[0];
if (!newItem) {
throw new Error('Subscription item not found on updated subscription');
}
const newPriceAmount = newItem.price?.unit_amount;
if (newPriceAmount == null) {
throw new Error('New price amount not found');
}
const quantity = newItem.quantity ?? 1;
const priceDifferenceAmount = (newPriceAmount - currentPriceAmount) * quantity;
// Create a one-off invoice item for the price difference if the new price is higher
if (priceDifferenceAmount > 0) {
await stripe.invoiceItems.create({
customer: updatedSubscription.customer as string,
amount: priceDifferenceAmount,
description: 'Onlook subscription upgrade',
});
// Create invoice immediately
const invoice = await stripe.invoices.create({
customer: updatedSubscription.customer as string,
auto_advance: true,
});
if (!invoice.id) {
throw new Error('Invoice not created');
}
await stripe.invoices.pay(invoice.id);
}
// Compute delta using the target Price (no subscription change yet)
const newPrice = await stripe.prices.retrieve(priceId);
const newPriceAmount = newPrice.unit_amount;
if (newPriceAmount == null) {
throw new Error('New price amount not found');
}
const quantity = currentItem.quantity ?? 1;
const currentCurrency = currentItem.price.currency;
const newCurrency = newPrice.currency;
if (currentCurrency && newCurrency && currentCurrency !== newCurrency) {
throw new Error('Cannot upgrade across different currencies');
}
const currency = newCurrency ?? currentCurrency;
if (!currency) {
throw new Error('Currency not found on price');
}
const priceDifferenceAmount = (newPriceAmount - currentPriceAmount) * quantity;
// Charge the delta first (if positive)
if (priceDifferenceAmount > 0) {
await stripe.invoiceItems.create({
customer: currentSubscription.customer as string,
amount: priceDifferenceAmount,
currency,
description: 'Onlook subscription upgrade',
});
const invoice = await stripe.invoices.create({
customer: currentSubscription.customer as string,
auto_advance: true,
collection_method: 'charge_automatically',
});
if (!invoice.id) {
throw new Error('Invoice not created');
}
await stripe.invoices.pay(invoice.id);
}
// Only after successful payment do we apply the subscription update without proration
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscriptionItemId,
price: priceId,
},
],
proration_behavior: 'none',
});
return updatedSubscription;


return updatedSubscription;
};

export const updateSubscriptionNextPeriod = async ({
subscriptionId,
priceId,
Expand Down