diff --git a/app/stores/products.ts b/app/stores/products.ts index 371637f09..301ec5da0 100644 --- a/app/stores/products.ts +++ b/app/stores/products.ts @@ -1,4 +1,9 @@ export const products = { + 'stripe-saas': { + id: 'stripe-saas', + price: 'price_1OyecaBF7AptWZlc4nRFI3Ei', + amount: 20, + }, 'sveltekit': { id: 'sveltekit', price: 'price_1NNq9RBF7AptWZlcrbO92SfW', diff --git a/content/courses/stripe-js/_index.md b/content/courses/stripe-js/_index.md index c2559b9f5..057b823d3 100644 --- a/content/courses/stripe-js/_index.md +++ b/content/courses/stripe-js/_index.md @@ -10,6 +10,7 @@ tags: - react vimeo: 416381401 +deprecated: true author: Jeff Delaney --- @@ -17,7 +18,7 @@ author: Jeff Delaney {{< figure src="/courses/stripe-js/img/review-scssat.png" alt="I’ve just finished your Stripe course. I want to give you the following feedback" >}}
- This course has been deprecated. A completely new Stripe course will replace this content in the near future (Spring 2023). + This course has been deprecated. Enroll in the Stripe for SaaS course for the latest and greatest content.
The **Stripe Payments JavaScript Course** is a project-based guide to building fullstack payment solutions on the web with Node.js and React. diff --git a/content/courses/stripe-saas/_index.md b/content/courses/stripe-saas/_index.md new file mode 100644 index 000000000..2b10055ac --- /dev/null +++ b/content/courses/stripe-saas/_index.md @@ -0,0 +1,55 @@ +--- +lastmod: 2024-03-24T11:11:30-09:00 +title: Stripe for SaaS +description: Accept Payments in your Software-as-a-Service Product with Stripe +weight: 0 +type: courses +vimeo: 927652411 +author: Jeff Delaney +tags: + - stripe + - pro + - typescript + +stack: + - stripe + - nextjs + - supabase +--- + +**Stripe for SaaS Full Course** is a hands-on tutorial where you will build a monetized web app with [Stripe](https://stripe.com/), [Next.js](https://nextjs.org/) and [Supabase](https://supabase.com/). + +## ⚡ What will I learn? + +- 💵 Everything you need to build complex payment flows with Stripe +- 💷 One-time payments +- 💶 Recurring subscriptions +- ⏱️ Metered pay-as-you-go billing +- 🎣 Handle and test Stripe webhooks locally +- 🤝 Payment and billing strategies for SaaS products +- 🔥 Master key concepts quickly with fast-paced vidoes +- ⚛️ Fullstack starter project with Next.js frontend and Supabase backend +- 🧑‍💻 Includes all source code and project files + +## 🦄 What will I build? + +You will build a **Stock Photography Subscription SaaS Product** from scratch where users can sign up for a subscription to access a library of images. Every monetized action is tracked in Stripe and the user is billed based on usage at the end of the month. + +The full project demonstates how to accept payments, manage recurring subscriptions, cancellations, metereing, and more. Watch the [App Tour](/courses/stripe-saas/project-tour/) video for a full breakdown of the project. + + +## 🤔 Is this Course Right for Me? + +
+This course is intermediate level 🟦 and expects some familiarity with JavaScript and web development. The content is fast-paced and similar to my style on YouTube, but far more in-depth and should be followed in a linear format. +
+ + +## When was the course last updated? + +Updated March 26th, 2024 Next.js 14 Supabase.js 2+ + +## How do I enroll? + +The first few videos are *free*, so just give it try. When you reach a paid module, you will be asked to pay for a single course or upgrade to PRO. + diff --git a/content/courses/stripe-saas/bonus-embedded-checkout.md b/content/courses/stripe-saas/bonus-embedded-checkout.md new file mode 100644 index 000000000..19ccbc434 --- /dev/null +++ b/content/courses/stripe-saas/bonus-embedded-checkout.md @@ -0,0 +1,15 @@ +--- +title: Embedded Checkout +description: Create an embedded stripe checkout page +weight: 40 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: +emoji: 🎊 +video_length: 3:00 +chapter_start: Bonus Round +--- + +### Coming Soon... + +This video is in develpment and will be available soon. Stay tuned! diff --git a/content/courses/stripe-saas/example-checkout.md b/content/courses/stripe-saas/example-checkout.md new file mode 100644 index 000000000..852f1ae3e --- /dev/null +++ b/content/courses/stripe-saas/example-checkout.md @@ -0,0 +1,78 @@ +--- +title: Checkout Session +description: Create a Stripe Checkout Session on the server +weight: 12 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619746 +emoji: 🛒 +video_length: 2:31 +--- + +### Extra Resources + +- Stripe Testing Cards [Link](https://docs.stripe.com/testing) + +### Prompt Template + +```text +Create a POST endpoint in [SOME WEB FRAMEWORK] that creates a Stripe Checkout Session using the code below as a reference. +``` + +### Code + +{{< file "ts" "src/index.ts" >}} +```typescript +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception'; +import Stripe from 'stripe'; +import 'dotenv/config' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + + +const app = new Hono() + +app.get('/success', (c) => { + return c.text('Success!') +}) + +app.get('/cancel', (c) => { + return c.text('Hello Hono!') +}) + + +app.post('/checkout', async (c) => { + + + try { + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price: 'price_YOUR_PRICE_ID', + quantity: 1, + }, + ], + mode: 'payment', + success_url: 'http://localhost:3000/success', + cancel_url: 'http://localhost:3000//cancel', + }); + + return c.json(session); + } catch (error: any) { + console.error(error); + throw new HTTPException(500, { message: error?.message }); + } +}); + +const port = 3000 +console.log(`Server is running on port ${port}`) + +serve({ + fetch: app.fetch, + port +}) +``` + diff --git a/content/courses/stripe-saas/example-env-vars.md b/content/courses/stripe-saas/example-env-vars.md new file mode 100644 index 000000000..932747a8e --- /dev/null +++ b/content/courses/stripe-saas/example-env-vars.md @@ -0,0 +1,57 @@ +--- +title: Environment Variables +description: Add the Stripe SDK to Environemt Variables +weight: 11 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619770 +emoji: 🔐 +video_length: 3:01 +--- + +### Commands + +Create a .env file and install the dotenv and stripe packages. + +```bash +touch .env +npm i dotenv stripe +``` + +### Prompt Template + +```text +Configure Stripe environemt variables in [SOME WEB FRAMEWORK] using the code below as a reference. +Use the environment variables to initialize the Stripe SDK. +``` + +### Code + +{{< file "cog" ".env" >}} +```text +STRIPE_PUBLISHABLE_KEY=pk_test_ +STRIPE_SECRET_KEY=sk_test_ +STRIPE_WEBHOOK_SECRET=whsec_ +``` + +{{< file "ts" "src/index.ts" >}} +```typescript +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import Stripe from 'stripe'; +import 'dotenv/config' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + + +const app = new Hono() + +const port = 3000 +console.log(`Server is running on port ${port}`) + +serve({ + fetch: app.fetch, + port +}) +``` + diff --git a/content/courses/stripe-saas/example-frontend.md b/content/courses/stripe-saas/example-frontend.md new file mode 100644 index 000000000..06265153c --- /dev/null +++ b/content/courses/stripe-saas/example-frontend.md @@ -0,0 +1,75 @@ +--- +title: Checkout Frontend +description: Trigger a Stripe Checkout Session from a web frontend +weight: 13 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619796 +emoji: 💻 +video_length: 1:33 +--- + + +### Prompt Template + +```text +Create a GET endpoint on the "/" route in [SOME WEB FRAMEWORK] that renders an HTML page. +The webpage should contain a button that triggers a POST request to the /checkout endpoint using the browser fetch API. +``` + +### Code + +{{< file "ts" "src/index.ts" >}} +```typescript +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception'; +import Stripe from 'stripe'; +import 'dotenv/config' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + + +const app = new Hono() + +app.get('/', (c) => { + const html = ` + + + + Checkout + + + +

Checkout

+ + + + + +`; + return c.html(html); +}) + +const port = 3000 +console.log(`Server is running on port ${port}`) + +serve({ + fetch: app.fetch, + port +}) +``` + diff --git a/content/courses/stripe-saas/example-hono.md b/content/courses/stripe-saas/example-hono.md new file mode 100644 index 000000000..eb39c70c1 --- /dev/null +++ b/content/courses/stripe-saas/example-hono.md @@ -0,0 +1,63 @@ +--- +title: Hono Backend +description: Create a web server with Node.js & Hono +weight: 10 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619817 +emoji: 🏯 +video_length: 3:22 +free: true +chapter_start: Simple Project +--- + +### Extra Resources + +REST APIs in 100 Seconds: [YouTube Video](https://youtu.be/-MTSQjw5DrM) + +### Commands + +Refer to the [Hono documentation](https://hono.dev/). + +```bash +npm create hono@latest my-app + +cd my-app + +npm run dev +``` + +### Prompt Template + +```text +Create a basic backend server with [SOME WEB FRAMEWORK]. +Create a GET route and POST route on the root path "/" that returns a text response with a message. Adapt the Hono code below as a reference. +``` + +### Code + +{{< file "ts" "src/index.ts" >}} +```typescript +import { serve } from '@hono/node-server' +import { Hono } from 'hono' + +const app = new Hono() + +app.get('/', (c) => { + c.text('GET it') +}) + +app.post('/', (c) => { + c.text('POST it') +}) + + +const port = 3000 +console.log(`Server is running on port ${port}`) + +serve({ + fetch: app.fetch, + port +}) +``` + diff --git a/content/courses/stripe-saas/example-stripe-cli.md b/content/courses/stripe-saas/example-stripe-cli.md new file mode 100644 index 000000000..c7ff6e7ac --- /dev/null +++ b/content/courses/stripe-saas/example-stripe-cli.md @@ -0,0 +1,26 @@ +--- +title: Stripe CLI +description: Configure the Stripe CLI for local development +weight: 14 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619845 +emoji: 🌐 +video_length: 2:10 +--- + +### Install + +Install the Stripe CLI by following the [Stripe CLI Install Docs](https://stripe.com/docs/stripe-cli) page. + +### Commands + +```bash +stripe login + +stripe listen -e checkout.session.completed --forward-to http://localhost:3000/webhook +``` + + + + diff --git a/content/courses/stripe-saas/example-webhook.md b/content/courses/stripe-saas/example-webhook.md new file mode 100644 index 000000000..599949ade --- /dev/null +++ b/content/courses/stripe-saas/example-webhook.md @@ -0,0 +1,74 @@ +--- +title: Webhooks +description: Create a secure webhook handler endpoint +weight: 15 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619867 +emoji: 🎣 +video_length: 3:14 +--- + + +### Prompt Template + +```text +Create a POST endpoint on the "/webhook" route in [SOME WEB FRAMEWORK] to handle Stripe webhook events. +The handler should verify the webhook signature and handle the "checkout.session.completed" event. +Use the code below as a reference: +``` + +### Code + +{{< file "ts" "src/index.ts" >}} +```typescript +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception'; +import Stripe from 'stripe'; +import 'dotenv/config' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + + +const app = new Hono() + +app.post('/webhook', async (c) => { + const rawBody = await c.req.text(); + const signature = c.req.header('stripe-signature'); + + let event; + try { + event = stripe.webhooks.constructEvent(rawBody, signature!, process.env.STRIPE_WEBHOOK_SECRET!); + } catch (error: any) { + console.error(`Webhook signature verification failed: ${error.message}`); + throw new HTTPException(400) + } + + // Handle the checkout.session.completed event + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + console.log(session) + + // TODO Fulfill the purchase with your own business logic, for example: + // Update Database with order details + // Add credits to customer account + // Send confirmation email + // Print shipping label + // Trigger order fulfillment workflow + // Update inventory + // Etc. + } + + return c.text('success'); +}) + +const port = 3000 +console.log(`Server is running on port ${port}`) + +serve({ + fetch: app.fetch, + port +}) +``` + diff --git a/content/courses/stripe-saas/img/featured.jpg b/content/courses/stripe-saas/img/featured.jpg new file mode 100644 index 000000000..b687400f1 Binary files /dev/null and b/content/courses/stripe-saas/img/featured.jpg differ diff --git a/content/courses/stripe-saas/img/featured.png b/content/courses/stripe-saas/img/featured.png new file mode 100644 index 000000000..aaed89359 Binary files /dev/null and b/content/courses/stripe-saas/img/featured.png differ diff --git a/content/courses/stripe-saas/img/featured.webp b/content/courses/stripe-saas/img/featured.webp new file mode 100644 index 000000000..361610866 Binary files /dev/null and b/content/courses/stripe-saas/img/featured.webp differ diff --git a/content/courses/stripe-saas/img/stripe-architecture.png b/content/courses/stripe-saas/img/stripe-architecture.png new file mode 100644 index 000000000..ef5adcd81 Binary files /dev/null and b/content/courses/stripe-saas/img/stripe-architecture.png differ diff --git a/content/courses/stripe-saas/intro-architecture.md b/content/courses/stripe-saas/intro-architecture.md new file mode 100644 index 000000000..eccec04d7 --- /dev/null +++ b/content/courses/stripe-saas/intro-architecture.md @@ -0,0 +1,34 @@ +--- +title: Architecture +description: Example SaaS architecture with Stripe +weight: 6 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619885 +emoji: 🏛️ +video_length: 2:06 +free: true +--- + + +### Example Stripe Architecture + +![Stripe SaaS Architecture](/courses/stripe-saas/img/stripe-architecture.png) + +Here's a summary of the key steps: +#### 1. Product & Price Setup +- Create a product in the Stripe dashboard representing your service. +- Define prices for the product (e.g., $20/month). +#### 2. Initiate Payment +- User clicks "Buy Now" button on your website. +- Your server generates a Stripe Checkout session with the relevant price ID. +- User gets redirected to Stripe's secure checkout page. +#### 3. Payment & Fulfillment +- User enters payment details on Stripe's checkout page. +- Upon successful payment, Stripe creates customer, payment, charge, and subscription records. +- Stripe sends a webhook to your server with checkout session information. +- Your server updates your database and sends a confirmation email to the user. +#### 4. Subscription Management +- User can cancel their subscription through a Stripe-hosted portal. +- Stripe sends a webhook notifying your server about the cancellation. +- Your server updates the user's record in your database accordingly. \ No newline at end of file diff --git a/content/courses/stripe-saas/intro-methods.md b/content/courses/stripe-saas/intro-methods.md new file mode 100644 index 000000000..64f3d0798 --- /dev/null +++ b/content/courses/stripe-saas/intro-methods.md @@ -0,0 +1,33 @@ +--- +title: Payment Experience +description: Four ways to accept Stripe payments +weight: 5 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619910 +emoji: 💳 +video_length: 3:21 +--- + + +## Payment Experience + +Stripe provides multiple ways to integrate payments into your application, catering to different use cases and developer preferences. Let's take a closer look at the options available. + +### Payment Links + +Payment Links offer the simplest way to start accepting payments. With Payment Links, you can create a shareable URL that directs customers to a pre-built payment page hosted by Stripe. This option requires minimal development effort and allows you to start accepting payments quickly. + +### Stripe Checkout + +Stripe Checkout is a customizable payment flow that provides a pre-built, mobile-optimized checkout experience. It handles the entire payment process, including collecting customer information, processing payments, and managing subscriptions. + +Integrating Stripe Checkout into your application is straightforward. By including the Stripe Checkout JavaScript library and configuring a few parameters, you can embed a "Pay with Card" button that launches the Stripe Checkout modal. Customers can then enter their payment details, and upon successful payment, they are redirected back to your application. + +### Stripe Elements + +For more control over the payment experience, Stripe Elements allows you to build custom checkout forms directly within your application. Elements provides a set of UI components that securely collect sensitive payment information, such as credit card details, while ensuring a consistent and stylish appearance across different browsers and devices.You have the flexibility to design your own checkout flow and maintain full control over the user experience. You can customize the look and feel of the form elements to match your application's branding and style. + +### REST API + +If you require a more low-level approach or need to build a custom payment flow, Stripe's REST API provides a set of bare-bones endpoints for managing payments, customers, subscriptions, and more. With the REST API, you have complete control over the payment process and can integrate Stripe's functionality into your application at a granular level. \ No newline at end of file diff --git a/content/courses/stripe-saas/intro-resources.md b/content/courses/stripe-saas/intro-resources.md new file mode 100644 index 000000000..a6b792ff6 --- /dev/null +++ b/content/courses/stripe-saas/intro-resources.md @@ -0,0 +1,17 @@ +--- +title: Resources +description: Stripe Course Resources +weight: 2 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619910 +emoji: 💾 +video_length: 3:21 +--- + + +### Course Resources + +- Soure Code: [GitHub Repo](https://github.com/fireship-io/stripe-for-saas) +- Stripe Docs: [API Reference](https://stripe.com/docs/api) +- Stripe in 100 Seconds: [YouTube](https://youtu.be/7edR32QVp_A) \ No newline at end of file diff --git a/content/courses/stripe-saas/intro-strategy.md b/content/courses/stripe-saas/intro-strategy.md new file mode 100644 index 000000000..b6d264cb0 --- /dev/null +++ b/content/courses/stripe-saas/intro-strategy.md @@ -0,0 +1,36 @@ +--- +title: Payment Strategies +description: Business strategies for revenue growth +weight: 3 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927619980 +emoji: 🤑 +video_length: 5:11 +free: true +--- + + +### Pricing Strategies to Consider + +##### Freemium: + +Offer a basic version of your software for free, with premium features behind a paywall. Users can try before they buy, but be wary of 'scammy' tactics that hide essential features until the last minute. +##### Free Trial: + +Let users try the full software for a limited time. This can be effective, but requiring a credit card upfront can reduce your potential user base. + +##### One-Time Payment: + +This upfront model yields quick returns but may be harder to justify than a recurring subscription, especially for ongoing services. + +##### Tiered Subscriptions: + + Offer basic, mid-tier, and premium plans at different price points (often with discounts for longer commitments). This is common for most SaaS products. + +##### Seat-Based Pricing: + +Charge per user, ideal for B2B and enterprise sales where multiple employees use the software. +##### Metered Billing: + +Bill users based on actual usage (e.g., per image generated). This aligns pricing directly with customer value but can be less predictable. \ No newline at end of file diff --git a/content/courses/stripe-saas/intro-stripe-100-seconds.md b/content/courses/stripe-saas/intro-stripe-100-seconds.md new file mode 100644 index 000000000..1efa8218e --- /dev/null +++ b/content/courses/stripe-saas/intro-stripe-100-seconds.md @@ -0,0 +1,17 @@ +--- +title: Stripe in 100 Seconds +description: Stripe explained as quickly as possible +weight: 1 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +youtube: 7edR32QVp_A +emoji: ⚡ +video_length: 1:50 +free: true +chapter_start: Basics +--- + + +## Notes + +This video originally appeared on YouTube and provides a high-level overview of the Stripe API. \ No newline at end of file diff --git a/content/courses/stripe-saas/project-auth.md b/content/courses/stripe-saas/project-auth.md new file mode 100644 index 000000000..dfde8e86f --- /dev/null +++ b/content/courses/stripe-saas/project-auth.md @@ -0,0 +1,73 @@ +--- +title: User Auth +description: Implement email/password user authentication +weight: 23 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927648917 +emoji: 🫂 +video_length: 2:10 +--- + +### Code + + +Create a React server component for the login page: + +{{< file "react" "app/user/page.tsx" >}} +```tsx +import LoginForm from "./LoginForm"; + +export default function Login() { + return ( +
+

Login

+ +
+ ); + } +``` + +Create a client component for the main login form: + +{{< file "react" "app/user/LoginForm.tsx" >}} +```tsx +"use client"; + +import { useState } from "react"; +import { supabase } from "../../utils/supabaseClient"; + +export default function LoginForm() { + const [loading, setLoading] = useState(false); + + const handleSignUp = async () => { + setLoading(true); + + const randomEmail = `${Math.random().toString(36).substring(7)}@example.com`; + const password = "Password69420"; + + const { data, error } = await supabase.auth.signUp({ + email: randomEmail, + password, + }); + + if (error) { + console.error(error); + } else { + console.log("User created and logged in:", data); + } + + setLoading(false); + }; + + return ( + + ); +} +``` + diff --git a/content/courses/stripe-saas/project-metered-billing.md b/content/courses/stripe-saas/project-metered-billing.md new file mode 100644 index 000000000..ed527d765 --- /dev/null +++ b/content/courses/stripe-saas/project-metered-billing.md @@ -0,0 +1,153 @@ +--- +title: Metered Billing +description: Record usage-based billing events +weight: 30 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927648994 +emoji: ⏱️ +video_length: 4:53 +--- + +### Code + + +{{< file "react" "app/photos/page.tsx" >}} +```tsx +import DownloadButton from "./DownloadButton"; + +export default function PhotosPage() { + const images = [ + 'img1.jpg', + 'img2.jpg', + 'img3.jpg', + ]; + + return ( +
+ {images.map((image, index) => ( +
+
+ {`Image +
+ +
+ ))} +
+ ); +} +``` + + +{{< file "react" "app/photos/DownloadButton.tsx" >}} +```tsx +"use client"; + +import { supabase } from "@/utils/supabaseClient"; +import toast from "react-hot-toast"; + +export default async function DownloadButton({ image }) { + const handleDownload = async () => { + const session = await supabase.auth.getSession(); + const token = session.data.session?.access_token; + + const res = await fetch("/api/usage-meter", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ image }), + }); + + if (res.ok) { + const { total_downloads } = await res.json(); + toast.success(`Success! You have downloaded ${total_downloads} images`); + } else { + const err = await res.json(); + toast.error(`Error! ${err.message}`); + } + }; + + return ( + <> + + + ); +} +``` + +{{< file "ts" "app/api/usage-meter/route.tsx" >}} +```typescript +import { NextResponse } from 'next/server'; +import { stripe } from '@/utils/stripe'; +import { supabaseAdmin } from '@/utils/supabaseServer'; + +export async function POST(request: Request) { + try { + + // Check if the user is logged in + const token = request.headers.get('Authorization')?.split('Bearer ')[1]; + if (!token) { + throw 'missing auth token'; + } + + const { data: { user }, error: userError } = await supabaseAdmin.auth.getUser(token); + + if (!user || userError) { + throw 'supabase auth error'; + } + + // Check the user's active_plan status in the stripe_customers table + const { data: customer, error: fetchError } = await supabaseAdmin + .from('stripe_customers') + .select('*') + .eq('user_id', user.id) + .single(); + + if (!customer || !customer.subscription_id || fetchError) { + throw 'Please subscribe to a plan to download the image.'; + } + + // Create a new record in the downloads table + const { image } = await request.json(); + + await supabaseAdmin + .from('downloads') + .insert({ user_id: user.id, image }); + + await supabaseAdmin + .from('stripe_customers') + .update({ total_downloads: customer.total_downloads + 1}) + .eq('user_id', user.id) + + const subscription = await stripe.subscriptions.retrieve(customer.subscription_id); + const subscriptionItem = subscription.items.data[0]; + const usageRecord = await stripe.subscriptionItems.createUsageRecord( + subscriptionItem.id, + { + quantity: 1, + timestamp: 'now', + action: "increment" + } + ); + + + return NextResponse.json({ message: 'Usage record created successfully!', total_downloads: customer.total_downloads + 1 }, { status: 200 }); + + + } catch (error: any) { + console.error(error); + return NextResponse.json({ message: error }, { status: 500 }); + } +} +``` \ No newline at end of file diff --git a/content/courses/stripe-saas/project-next.md b/content/courses/stripe-saas/project-next.md new file mode 100644 index 000000000..08b468282 --- /dev/null +++ b/content/courses/stripe-saas/project-next.md @@ -0,0 +1,78 @@ +--- +title: Next.js Setup +description: Create a new Next.js project +weight: 21 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927649061 +emoji: ⚛️ +video_length: 3:07 +--- + +### Commands + +Refer to the [Next.js documentation](https://nextjs.org/docs/getting-started/installation) + +```bash +npx create-next-app my-app + +cd my-app + +# optional styling libraries +npm i -D @tailwindcss/typography daisyui + +# install Stripe +npm i stripe @stripe/stripe-js + +npm run dev +``` + + +### Code + +Add Environment Variables to your `.env.local` file. + +{{< file "cog" ".env.local" >}} +```env +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_ +STRIPE_SECRET_KEY=sk_test_ +STRIPE_WEBHOOK_SECRET=whsec_ +``` + +Remove the Next.js boilerplate. + +{{< file "react" "app/page.tsx" >}} +```tsx +export default function Home() { + return ( +
+ +
+ ); +} +``` + +Optionally, update your Tailwdind configuration to include the DaisyUI and Tailwind Typography plugins for rapid styling. + +{{< file "tailwind" "tailwind.config.ts" >}} +```typescript +import type { Config } from "tailwindcss"; +import daisyui from 'daisyui'; +import tailwindTypography from '@tailwindcss/typography'; + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + + }, + plugins: [ + tailwindTypography, + daisyui, + ], +}; +export default config; +``` \ No newline at end of file diff --git a/content/courses/stripe-saas/project-portal.md b/content/courses/stripe-saas/project-portal.md new file mode 100644 index 000000000..2a6ee987f --- /dev/null +++ b/content/courses/stripe-saas/project-portal.md @@ -0,0 +1,104 @@ +--- +title: Billing Portal +description: Manage subscription cancellations in the Stripe portal +weight: 29 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927649081 +emoji: 🍞 +video_length: 4:12 +--- + +### Code + + +{{< file "ts" "app/portal/portalAction.ts" >}} +```typescript +'use server'; + +import { stripe } from "@/utils/stripe"; + + +export async function createPortalSession(customerId: string) { + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `http://localhost:3000`, + }); + + return { id: portalSession.id, url: portalSession.url }; +} +``` + + +{{< file "react" "app/portal/PortalButton.tsx" >}} +```tsx +'use client'; + +import { createPortalSession } from './portalAction'; +import { supabase } from '@/utils/supabaseClient'; +import toast from 'react-hot-toast'; + +export default function PortalButton() { + const handleClick = async () => { + try { + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + throw 'Please log in to manage your billing.'; + } + + const { data: customer, error: fetchError } = await supabase + .from('stripe_customers') + .select('stripe_customer_id') + .eq('user_id', user.id) + .single(); + + const { url } = await createPortalSession(customer?.stripe_customer_id); + + window.location.href = url; + + } catch (error) { + console.error(error); + toast.error('Failed to create billing portal session:'); + } + } + + return ( + <> + + + ); +} +``` + +{{< file "ts" "app/api/webhook/route.tsx" >}} +```typescript + + //... omitted webhook signature verification + + if (event.type === 'customer.subscription.updated') { + + const subscription: Stripe.Subscription = event.data.object; + console.log(subscription); + // Update the plan_expires field in the stripe_customers table + const { error } = await supabaseAdmin + .from('stripe_customers') + .update({ plan_expires: subscription.cancel_at }) + .eq('subscription_id', subscription.id); + + } + + if (event.type === 'customer.subscription.deleted') { + + const subscription = event.data.object; + console.log(subscription); + + const { error } = await supabaseAdmin + .from('stripe_customers') + .update({ plan_active: false, subscription_id: null }) + .eq('subscription_id', subscription.id); + + } +``` \ No newline at end of file diff --git a/content/courses/stripe-saas/project-subscription-button.md b/content/courses/stripe-saas/project-subscription-button.md new file mode 100644 index 000000000..df3797eb5 --- /dev/null +++ b/content/courses/stripe-saas/project-subscription-button.md @@ -0,0 +1,52 @@ +--- +title: Checkout Redirect +description: Redirect to a Checkout Session +weight: 27 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927648940 +emoji: 🎞️ +video_length: 2:09 +--- + + +{{< file "react" "app/CheckoutButton.tsx" >}} +```tsx +'use client'; + +import { loadStripe } from '@stripe/stripe-js'; +import { supabase } from '@/utils/supabaseClient'; +import toast from 'react-hot-toast'; + + +export default function CheckoutButton() { + const handleCheckout = async() => { + const { data } = await supabase.auth.getUser(); + + if (!data?.user) { + toast.error("Please log in to create a new Stripe Checkout session"); + return; + } + + const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); + const stripe = await stripePromise; + const response = await fetch('/api/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'price_1OtHkdBF7AptWZlcIjbBpS8r', userId: data.user?.id, email: data.user?.email }), + }); + const session = await response.json(); + await stripe?.redirectToCheckout({ sessionId: session.id }); + } + + return ( +
+

Signup for a Plan

+

Clicking this button creates a new Stripe Checkout session

+ +
+ ); +} +``` \ No newline at end of file diff --git a/content/courses/stripe-saas/project-subscription-endpoint.md b/content/courses/stripe-saas/project-subscription-endpoint.md new file mode 100644 index 000000000..a85c9c3fe --- /dev/null +++ b/content/courses/stripe-saas/project-subscription-endpoint.md @@ -0,0 +1,100 @@ +--- +title: Start a Subscription +description: Create a Stripe subscription endpoint +weight: 26 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927648971 +emoji: 💫 +video_length: 3:36 +--- + +### Code + + +{{< file "ts" "utils/stripe.ts" >}} +```typescript +import Stripe from 'stripe'; + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); +``` + +{{< file "ts" "app/api/checkout/route.ts" >}} +```tsx +import { NextResponse } from 'next/server'; +import { stripe } from '@/utils/stripe'; + +export async function POST(request: Request) { + try { + const { priceId, email, userId } = await request.json(); + + const session = await stripe.checkout.sessions.create({ + metadata: { + user_id: userId, + }, + customer_email: email, + payment_method_types: ['card'], + line_items: [ + { + // base subscription + price: priceId, + }, + { + // one-time setup fee + price: 'price_1OtHdOBF7AptWZlcPmLotZgW', + quantity: 1, + }, + ], + mode: 'subscription', + success_url: `${request.headers.get('origin')}/success`, + cancel_url: `${request.headers.get('origin')}/cancel`, + }); + + return NextResponse.json({ id: session.id }); + } catch (error: any) { + console.error(error); + return NextResponse.json({ message: error.message }, { status: 500 }); + } +} +``` + +{{< file "react" "app/CheckoutButton.tsx" >}} +```tsx +'use client'; + +import { loadStripe } from '@stripe/stripe-js'; +import { supabase } from '../utils/supabaseClient'; +import toast from 'react-hot-toast'; + + +export default function CheckoutButton() { + const handleCheckout = async() => { + const { data } = await supabase.auth.getUser(); + + if (!data?.user) { + toast.error("Please log in to create a new Stripe Checkout session"); + return; + } + + const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); + const stripe = await stripePromise; + const response = await fetch('/api/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'price_1OtHkdBF7AptWZlcIjbBpS8r', userId: data.user?.id, email: data.user?.email }), + }); + const session = await response.json(); + await stripe?.redirectToCheckout({ sessionId: session.id }); + } + + return ( +
+

Signup for a Plan

+

Clicking this button creates a new Stripe Checkout session

+ +
+ ); +} +``` \ No newline at end of file diff --git a/content/courses/stripe-saas/project-supabase.md b/content/courses/stripe-saas/project-supabase.md new file mode 100644 index 000000000..88ce581da --- /dev/null +++ b/content/courses/stripe-saas/project-supabase.md @@ -0,0 +1,87 @@ +--- +title: Database Setup +description: Create a new Postgres database with Supabase +weight: 22 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927649107 +emoji: 📅 +video_length: 3:27 +--- + +### Supabase Project + +Create a free [Supabase](https://supabase.com/) project and database. Add your environment variables to the `.env.local` file. + +Go to `auth >> providers >> email` and enable email sign-in. Set the `confirm email` option to FALSE. + +### Commands + +Install the Supabase client library. + +```bash +npm i @supabase/supabase-js +``` + + +### Code + +Update your `.env.local` file with the following: + +{{< file "cog" ".env.local" >}} +```env +NEXT_PUBLIC_SUPABASE_ANON_KEY= +NEXT_PUBLIC_SUPABASE_URL=https://....supabase.co +SUPABASE_SECRET_KEY= +``` + +The `supabaseClient` file is used to interact with the Supabase on the frontend. + + +{{< file "ts" "utils/supabaseClient.ts" >}} +```tsx +import { createClient } from '@supabase/supabase-js' +export const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! +) +``` + +The `supabaseServer` file is used to create a client that connects ONLY to a secure backend. It bypasses row-level security and is used for admin tasks. + +{{< file "ts" "utils/supabaseServer.ts" >}} +```tsx +import { createClient } from '@supabase/supabase-js' +export const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY! +) +``` + +Create the necessary database tables by pasting this code into the Supabase SQL editor: + +```sql +create table + public.stripe_customers ( + id uuid not null default uuid_generate_v4 (), + user_id uuid not null, + stripe_customer_id text not null, + total_downloads integer null default 0, + plan_active boolean not null default false, + plan_expires bigint null, + subscription_id text null, + constraint stripe_customers_pkey primary key (id), + constraint stripe_customers_stripe_customer_id_key unique (stripe_customer_id), + constraint stripe_customers_user_id_fkey foreign key (user_id) references auth.users (id) + ) tablespace pg_default; + +create table + public.downloads ( + id uuid not null default uuid_generate_v4 (), + user_id uuid not null, + ts timestamp without time zone null default now(), + image text null, + constraint downloads_pkey primary key (id), + constraint downloads_user_id_fkey foreign key (user_id) references auth.users (id) + ) tablespace pg_default; +``` diff --git a/content/courses/stripe-saas/project-toast-nav.md b/content/courses/stripe-saas/project-toast-nav.md new file mode 100644 index 000000000..2e4dbd628 --- /dev/null +++ b/content/courses/stripe-saas/project-toast-nav.md @@ -0,0 +1,83 @@ +--- +title: Toast & Navigation +description: Display toast messages and create a navigation bar +weight: 25 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927649028 +emoji: 🍞 +video_length: 1:13 +--- + +### Commands + +```bash +npm i react-hot-toast +``` + + +### Code + + +{{< file "react" "app/layout.tsx" >}} +```tsx +import type { Metadata } from "next"; +import Link from "next/link"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { Toaster } from "react-hot-toast"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
{children}
+ + + + ); +} + +const Navbar = () => { + return ( +
+
+ + 🔥 Stripe for SaaS + +
+
+
    +
  • + + Home + +
  • +
  • + + Photos + +
  • +
  • + + User Auth + +
  • +
+
+
+ ); +}; +``` \ No newline at end of file diff --git a/content/courses/stripe-saas/project-tour.md b/content/courses/stripe-saas/project-tour.md new file mode 100644 index 000000000..8b46569f7 --- /dev/null +++ b/content/courses/stripe-saas/project-tour.md @@ -0,0 +1,16 @@ +--- +title: Project Tour +description: Full metered subscription demo overview +weight: 20 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927649129 +emoji: 💸 +video_length: 2:43 +free: true +chapter_start: Full Project +--- + +## Resources + +Clone the full project source code on GitHub: [Stripe-for-SaaS](https://github.com/fireship-io/stripe-for-saas) \ No newline at end of file diff --git a/content/courses/stripe-saas/project-user-profile.md b/content/courses/stripe-saas/project-user-profile.md new file mode 100644 index 000000000..503f7e1ec --- /dev/null +++ b/content/courses/stripe-saas/project-user-profile.md @@ -0,0 +1,134 @@ +--- +title: User Profile +description: Fetch and display the current user's data +weight: 24 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927649154 +emoji: 🫂 +video_length: 3:32 +--- + +### Code + +Create a React server component for the login page: + +{{< file "react" "app/user/UserProfile.tsx" >}} +```tsx +"use client"; + +import { useState, useEffect } from "react"; +import { supabase } from "../../utils/supabaseClient"; +import { User } from "@supabase/supabase-js"; +import LoginForm from "./LoginForm"; + +export default function UserProfile() { + const [user, setUser] = useState(null); + const [stripeCustomer, setStripeCustomer] = useState(null); + + useEffect(() => { + const fetchUser = async () => { + const { data: { user } } = await supabase.auth.getUser(); + + setUser(user); + + if (user) { + const { data: stripeCustomerData, error } = await supabase + .from("stripe_customers") + .select("*") + .eq("user_id", user.id) + .single(); + + if (error) { + console.log("No stripe customer data found",); + } else { + setStripeCustomer(stripeCustomerData); + } + } + }; + + fetchUser(); + + const { data: authListener } = supabase.auth.onAuthStateChange( + (event, session) => { + if (event === "SIGNED_IN") { + if (session) { + setUser(session.user); + } + } else if (event === "SIGNED_OUT") { + setUser(null); + setStripeCustomer(null); + } + } + ); + + return () => { + authListener.subscription.unsubscribe(); + }; + }, []); + + const handleLogout = async () => { + await supabase.auth.signOut(); + }; + + return ( +
+

User Data

+ {user ? ( + <> +

+ Signed in with email: {user.email} +

+

+ Supabase User ID: {user.id} +

+
+ +
+ +

Stripe Customer Data

+ {stripeCustomer ? (<> +

This data lives in the stripe_customers table in Supabase

+
+
+                {JSON.stringify(stripeCustomer, null, 2)}
+              
+
+ + ) : ( +
+

+ Stripe customer data not created yet. Buy a plan! +

+
+ )} + + + ) : ( + <> +

No user logged in.

+ + + )} + +
+ ); +} +``` + +Update the `app/user/page.tsx` file to include the new `UserProfile` component: + +{{< file "react" "app/user/page.tsx" >}} +```tsx +import UserProfile from "./UserProfile"; + +export default function Login() { + return ( + <> + + + ); +} +``` \ No newline at end of file diff --git a/content/courses/stripe-saas/project-webhook-fulfillment.md b/content/courses/stripe-saas/project-webhook-fulfillment.md new file mode 100644 index 000000000..5258d3828 --- /dev/null +++ b/content/courses/stripe-saas/project-webhook-fulfillment.md @@ -0,0 +1,76 @@ +--- +title: Subscription Fulfillment +description: Fulfill subscription orders with webhooks +weight: 28 +lastmod: 2024-03-22T10:23:30-09:00 +draft: false +vimeo: 927664473 +emoji: 🎁 +video_length: 3:41 +--- + + +### Commands + +```bash +stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://localhost:3000/api/webhook +``` + + +### Code + + +{{< file "react" "app/api/webhook/route.tsx" >}} +```tsx +import { NextRequest, NextResponse } from 'next/server'; +import { stripe } from '@/utils/stripe'; +import { supabaseAdmin } from '@/utils/supabaseServer'; +import Stripe from 'stripe'; + +export async function POST(request: NextRequest) { + try { + const rawBody = await request.text(); + const signature = request.headers.get('stripe-signature'); + + let event; + try { + event = stripe.webhooks.constructEvent(rawBody, signature!, process.env.STRIPE_WEBHOOK_SECRET!); + } catch (error: any) { + console.error(`Webhook signature verification failed: ${error.message}`); + return NextResponse.json({ message: 'Webhook Error' }, { status: 400 }); + } + + // Handle the checkout.session.completed event + if (event.type === 'checkout.session.completed') { + const session: Stripe.Checkout.Session = event.data.object; + console.log(session); + const userId = session.metadata?.user_id; + + // Create or update the stripe_customer_id in the stripe_customers table + const { error } = await supabaseAdmin + .from('stripe_customers') + .upsert({ + user_id: userId, + stripe_customer_id: session.customer, + subscription_id: session.subscription, + plan_active: true, + plan_expires: null + }) + + + } + + if (event.type === 'customer.subscription.updated') { + + } + + if (event.type === 'customer.subscription.deleted') { + + } + + return NextResponse.json({ message: 'success' }); + } catch (error: any) { + return NextResponse.json({ message: error.message }, { status: 500 }); + } + } +``` \ No newline at end of file diff --git a/layouts/partials/svg/tailwind.svg b/layouts/partials/svg/tailwind.svg new file mode 100644 index 000000000..8e5abb917 --- /dev/null +++ b/layouts/partials/svg/tailwind.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icons/nextjs.svg b/static/img/icons/nextjs.svg index 9fd93149e..b4aa33aae 100644 --- a/static/img/icons/nextjs.svg +++ b/static/img/icons/nextjs.svg @@ -1,3 +1,21 @@ - - + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/icons/stripe.svg b/static/img/icons/stripe.svg new file mode 100644 index 000000000..553e224fd --- /dev/null +++ b/static/img/icons/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file