Skip to content

Commit

Permalink
Merge branch 'main' into DX-1351-third-party
Browse files Browse the repository at this point in the history
  • Loading branch information
CahidArda committed Dec 10, 2024
2 parents e0011e9 + 651ed05 commit 2bff7c7
Show file tree
Hide file tree
Showing 23 changed files with 634 additions and 0 deletions.
Binary file modified bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions examples/nextjs-webhook-stripe/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
40 changes: 40 additions & 0 deletions examples/nextjs-webhook-stripe/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
68 changes: 68 additions & 0 deletions examples/nextjs-webhook-stripe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Upstash Workflow Nextjs Example with Webhooks Example

This example demonstrates how to use Upstash Workflow with Next.js to handle Clerk webhook events, manage Stripe subscriptions, and send automated emails.

## Features

- Webhook handling for Clerk user events
- Stripe customer and subscription management
- Automated email sending with Resend
- Trial period management
- Event-driven workflow orchestration
-
## Prerequisites

- Clerk account and API keys
- Stripe account and API keys
- Resend account and API key
- Upstash account and QStash credentials

## Development

1. Install the dependencies

```bash
npm install
```

2. Set up your environment variables in `.env.local`:

```shell .env.local
QSTASH_URL=
QSTASH_TOKEN=
CLERK_WEBHOOK_SECRET=
STRIPE_SECRET_KEY=
RESEND_API_KEY=
```

3. Open a local tunnel to your development server:

```bash
ngrok http 3000
```

Set the UPSTASH_WORKFLOW_URL environment variable to the ngrok URL.

4. Start the development server.

Then, run the `create-user.sh` script in the `sh` folder.

```bash
./sh/create-user.sh
```

## Workflow Steps

The example implements the following workflow:

1. Validate incoming Clerk webhook on `/api/workflow/onboarding` endpoint
2. Process user creation events
3. Create Stripe customer
4. Send welcome email
5. Set up trial subscription
6. Wait for `await-payment-method` event. If a payment method is added to user, `/api/workflow/stripe` endpoint will be triggered by Stripe workflow.
7. Handle trial period completion
8. Send appropriate follow-up emails

## Contributing
Contributions are welcome! Please read our contributing guidelines before submitting pull requests.
187 changes: 187 additions & 0 deletions examples/nextjs-webhook-stripe/app/api/workflow/onboarding/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { serve } from "@upstash/workflow/nextjs";
import Stripe from "stripe"
import { Resend } from 'resend'

import { WebhookEvent } from "@clerk/nextjs/server";
import { Webhook } from 'svix';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "");
const resend = new Resend(process.env.RESEND_API_KEY ?? "")
export type OnboardingPayload = {
event: string;
clerkUserId: string;
email: string;
firstName: string;
lastName: string;
}

async function validateRequest(payloadString: string, headerPayload: Headers) {

const svixHeaders = {
"svix-id": headerPayload.get("svix-id") as string,
"svix-timestamp": headerPayload.get("svix-timestamp") as string,
"svix-signature": headerPayload.get("svix-signature") as string,
}
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET ?? "");
return wh.verify(payloadString, svixHeaders) as WebhookEvent;
}

export const { POST } = serve<string>(async (context) => {
const payloadString = context.requestPayload;
const headerPayload = context.headers;

let event: WebhookEvent;
try {
event = await validateRequest(payloadString, headerPayload);
} catch {
return
}

const user = await context.run<false | OnboardingPayload>("handle-clerk-webhook-event", async () => {
if (event.type === "user.created") {
const { id: clerkUserId, email_addresses, first_name, last_name } = event.data;
const primaryEmail = email_addresses.find(email => email.id === event.data.primary_email_address_id)

if (!primaryEmail) {
return false
}

return {
event: event.type,
clerkUserId: clerkUserId,
email: primaryEmail.email_address,
firstName: first_name,
lastName: last_name,
} as OnboardingPayload
}
return false
})

if (!user) {
return
}

const customer = await context.run("create-stripe-customer", async () => {
return await stripe.customers.create({
email: user.email,
name: `${user.firstName} ${user.lastName}`,
metadata: {
clerkUserId: user.clerkUserId
}
})
})

await context.run("send-welcome-email", async () => {
console.log("Sending welcome email to:", user.email)

await resend.emails.send({
from: '[email protected]',
to: user.email,
subject: 'Welcome to Your Trial!',
html: `
<h1>Welcome ${user.firstName || 'there'}!</h1>
<p>Thanks for signing up! Your trial starts now.</p>
<p>You have 7 days to explore all our premium features.</p>
<p>What you get with your trial:</p>
<ul>
<li>Feature 1</li>
<li>Feature 2</li>
<li>Feature 3</li>
</ul>
<p>Get started now: <a href="${process.env.NEXT_PUBLIC_URL}/dashboard">Visit Dashboard</a></p>
`
});

})

const subscription = await context.run("create-trial", async () => {
return await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: "price_1QQQWaCKnqweyLP9MPbARyG" }],
trial_period_days: 7,
metadata: {
clerkUserId: user.clerkUserId,
workflowRunId: context.workflowRunId
}
})
})

await context.run("store-subscription", async () => {
console.log(subscription)
})


/**
* This is where we start waiting for the payment method to be added to the subscription.
* If the payment method is added within 7 days, workflow on the `api/stripe/route` will notify this workflow with `payment_method_<SUBSCRIPTION_ID>`.
* If the payment method is not added within 7 days, we will handle the trial end.
*/
const { timeout } = await context.waitForEvent("await-payment-method", `payment_method_${subscription.id}`, {
timeout: "7d"
})


if (!timeout) {
await context.run("send-subscription-start-welcome-mail", async () => {
console.log("Sending subscription started email to:", user.email)

await resend.emails.send({
from: '[email protected]',
to: user.email,
subject: 'Payment Method Added Successfully!',
html: `
<h1>Thank you for adding your payment method!</h1>
<p>Your subscription will continue automatically after the trial period.</p>
<p>Your trial benefits:</p>
<ul>
<li>Unlimited access to all features</li>
<li>Priority support</li>
<li>No interruption in service</li>
</ul>
<p>Need help? Reply to this email or visit our support center.</p>
`
});
})

} else {
await context.run("handle-trial-end", async () => {
await stripe.subscriptions.update(subscription.id, {
cancel_at_period_end: true
})

return { status: 'trial_ended' }
})


await context.run("send-trial-ending-mail", async () => {
console.log("Sending trial ending email to:", user.email)

await resend.emails.send({
from: '[email protected]',
to: user.email,
subject: 'Your Trial is Ending Soon',
html: `
<h1>Don't Lose Access!</h1>
<p>Your trial is coming to an end. Add a payment method to keep your access:</p>
<ul>
<li>Keep all your data and settings</li>
<li>Continue using premium features</li>
<li>No interruption in service</li>
</ul>
<a href="${process.env.NEXT_PUBLIC_URL}/billing" style="
display: inline-block;
padding: 12px 24px;
background-color: #0070f3;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
">Add Payment Method</a>
<p>Questions? Contact our support team!</p>
`
});

})
}

}, { baseUrl: "<BASE_URL>", initialPayloadParser: (payload) => { return payload } })
54 changes: 54 additions & 0 deletions examples/nextjs-webhook-stripe/app/api/workflow/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { headers } from "next/headers";
import Stripe from "stripe";
import { Client } from "@upstash/workflow"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const wc = new Client({
token: process.env.QSTASH_TOKEN ?? ""
});

export async function POST(request: Request) {
const body = await request.text();
const headerList = await headers();
const signature = headerList.get("stripe-signature") as string;

try {
const event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET ?? "");

if (event.type === "payment_method.attached") {
const paymentMethod = event.data.object;
const customer = await stripe.customers.retrieve(paymentMethod.customer as string);

const subscriptions = await stripe.subscriptions.list({
customer: paymentMethod.customer as string,
status: "trialing"
})

const trialSubscription = subscriptions.data[0];

if (trialSubscription) {
/**
* This is where we notify the Workflow on the `api/workflow/onboarding` endpoint when a payment method is attached to a customer.
* Whether this event is notified within 7 days(arbitrary, customizable timeout), can be handled in onboarding workflow.
*/
await wc.notify({
eventId: `payment_method_${trialSubscription.id}`, eventData: {
customerId: customer.id,
paymentMethodId: paymentMethod.id,
addedAt: new Date().toISOString()
}
})
}

}

return Response.json({ received: true });
} catch (error) {
console.error('Stripe webhook error:', error);
return Response.json(
{ error: 'Webhook error occurred' },
{ status: 400 }
);
}
}
Binary file added examples/nextjs-webhook-stripe/app/favicon.ico
Binary file not shown.
Binary file not shown.
Binary file not shown.
21 changes: 21 additions & 0 deletions examples/nextjs-webhook-stripe/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
--background: #ffffff;
--foreground: #171717;
}

@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}

body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
Loading

0 comments on commit 2bff7c7

Please sign in to comment.