Skip to content

Commit

Permalink
Merge pull request #16 from iuioiua/webhooks
Browse files Browse the repository at this point in the history
refactor: webhooks-based approach
  • Loading branch information
iuioiua authored Mar 31, 2023
2 parents 49d1ca7 + 8f506e6 commit 9e7dc71
Show file tree
Hide file tree
Showing 18 changed files with 285 additions and 145 deletions.
4 changes: 3 additions & 1 deletion .example.env
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_URL=http://localhost:54321
SUPABSE_SERVICE_KEY=xxx

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=xxx
58 changes: 43 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Deno SaaSKit

[Deno SaaSKit](https://deno.com/saaskit) is an open sourced, highly performant
[Deno SaaSKit](https://deno.com/saaskit) is an open-sourced, highly performant
template for building your SaaS quickly and easily. This template ships with
these foundational features that every SaaS needs:

- User accounts
- User creation flows
- Landing page
- Pricing section
- Signin and session management
- Sign-in and session management
- Billing integration via Stripe
- Gated API endpoints

Expand Down Expand Up @@ -52,7 +52,9 @@ The only variables you need are:

- `SUPABASE_ANON_KEY`
- `SUPABASE_URL`
- `SUPABSE_SERVICE_KEY`
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`

Continue below to learn where to grab these keys.

Expand All @@ -73,7 +75,7 @@ Once ready, you can create a Supabase account and then
[create a new Supabase project](https://app.supabase.com/projects).

Once your project is created, you can grab your `SUPABASE_URL` and
`SUPABASE_ANON_KEY` from the
`SUPABASE_ANON_KEY` from
[Settings > API](https://app.supabase.com/project/_/settings/api).

### Create a `todos` table
Expand Down Expand Up @@ -106,6 +108,35 @@ You can also keep the column `created_at` if you'd like.

Hit save and then your table should be created.

### Create a `customers` table

- Go to `Database` > `Tables`
- Click `New Table`
- Enter the name as `customers` and check `Enable Row Level Security (RLS)`
- Configure the following columns:

| Name | Type | Default value | Primary |
| -------------------- | ------ | ------------- | ------- |
| `user_id` | `uuid` | `auth.uid()` | `true` |
| `stripe_customer_id` | `text` | `NULL` | `false` |
| `is_subscribed` | `bool` | `false` | `false` |

- Click the link symbol next to the `user_id` column name, select schema `auth`,
table `users`, and column `id`. Now the `user_id` will link back to a user
object in Supabase Auth.

### Automate Stripe subscription updates via Supabase

In Stripe, register a webhook endpoint by following
[this guide](https://stripe.com/docs/development/dashboard/register-webhook)
with the following values:

- Endpoint URL = `https://<SITE HOSTNAME>/api/subscription`
- Listen to `Events on your account`
- Select events to listen to:
- `customer.subscription.created`
- `customer.subscription.deleted`

### Setup authentication

[Supabase Auth](https://supabase.com/docs/guides/auth/overview) makes it simple
Expand All @@ -119,16 +150,18 @@ To setup Supabase Auth:

- Go to `Authentication` > `Providers` > `Email`
- Disable `Confirm email`
- Back on the left hand bar, under `Configuration`, click on `Policies`
- Click `New Policy` and then `Create a policy from scratch`
- Back on the left-hand bar, under `Configuration`, click on `Policies`
- Click `New Policy` on the `customers` table pane and then
`Create a policy from scratch`
- Enter the policy name as `Enable all operations for users based on user_id`
- For `Allowed operation`, select `All`
- For `Target Roles` select `authenticated`
- Enter the `USING expression` as `(auth.uid() = user_id)`
- Enter the `WITH CHECK expression` as `(auth.uid() = user_id)`
- Click `Review` then `Save policy`
- Repeat for the `todos` table pane

These steps enable using email with Supabase Auth, and provides a simple
These steps enable using email with Supabase Auth and provide a simple
authentication strategy restricting each user to only create, read, update, and
delete their own data.

Expand All @@ -137,7 +170,7 @@ delete their own data.
Currently, Deno SaaSKit uses [Stripe](https://stripe.com) for subscription
billing. In the future, we are open to adding other payment processors.

To setup Stripe:
To set up Stripe:

- Create a Stripe account
- Since upgrading to a paid tier will take you directly to Stripe's domain, we
Expand All @@ -159,13 +192,8 @@ Once you have all of this setup, you should be able to run Deno SaaSKit locally.

### Running locally

You can start the project by running:

```
deno task start
```

And going to `localhost:8000` on your browser.
You can start the project by running: And go to `localhost:8000`` on your
browser.

## Customizing Deno SaaSKit

Expand Down Expand Up @@ -211,7 +239,7 @@ can be found in these locations:

### Dashboard

This template comes with a simple To Do checklist app. All of the logic for that
This template comes with a simple To-Do checklist app. All of the logic for that
can be found:

- `/routes/dashboard/api/todo.ts`: the API route to handle creating and deleting
Expand Down
46 changes: 24 additions & 22 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ import * as $1 from "./routes/_500.tsx";
import * as $2 from "./routes/api/login.ts";
import * as $3 from "./routes/api/logout.ts";
import * as $4 from "./routes/api/signup.ts";
import * as $5 from "./routes/dashboard/_middleware.ts";
import * as $6 from "./routes/dashboard/account.tsx";
import * as $7 from "./routes/dashboard/api/todo.ts";
import * as $8 from "./routes/dashboard/index.tsx";
import * as $9 from "./routes/dashboard/manage-subscription.ts";
import * as $10 from "./routes/dashboard/todos.tsx";
import * as $11 from "./routes/dashboard/upgrade-subscription.ts";
import * as $12 from "./routes/index.tsx";
import * as $13 from "./routes/login.tsx";
import * as $14 from "./routes/logout.ts";
import * as $15 from "./routes/signup.tsx";
import * as $5 from "./routes/api/subscription.ts";
import * as $6 from "./routes/dashboard/_middleware.ts";
import * as $7 from "./routes/dashboard/account.tsx";
import * as $8 from "./routes/dashboard/api/todo.ts";
import * as $9 from "./routes/dashboard/index.tsx";
import * as $10 from "./routes/dashboard/manage-subscription.ts";
import * as $11 from "./routes/dashboard/todos.tsx";
import * as $12 from "./routes/dashboard/upgrade-subscription.ts";
import * as $13 from "./routes/index.tsx";
import * as $14 from "./routes/login.tsx";
import * as $15 from "./routes/logout.ts";
import * as $16 from "./routes/signup.tsx";
import * as $$0 from "./islands/TodoList.tsx";

const manifest = {
Expand All @@ -28,17 +29,18 @@ const manifest = {
"./routes/api/login.ts": $2,
"./routes/api/logout.ts": $3,
"./routes/api/signup.ts": $4,
"./routes/dashboard/_middleware.ts": $5,
"./routes/dashboard/account.tsx": $6,
"./routes/dashboard/api/todo.ts": $7,
"./routes/dashboard/index.tsx": $8,
"./routes/dashboard/manage-subscription.ts": $9,
"./routes/dashboard/todos.tsx": $10,
"./routes/dashboard/upgrade-subscription.ts": $11,
"./routes/index.tsx": $12,
"./routes/login.tsx": $13,
"./routes/logout.ts": $14,
"./routes/signup.tsx": $15,
"./routes/api/subscription.ts": $5,
"./routes/dashboard/_middleware.ts": $6,
"./routes/dashboard/account.tsx": $7,
"./routes/dashboard/api/todo.ts": $8,
"./routes/dashboard/index.tsx": $9,
"./routes/dashboard/manage-subscription.ts": $10,
"./routes/dashboard/todos.tsx": $11,
"./routes/dashboard/upgrade-subscription.ts": $12,
"./routes/index.tsx": $13,
"./routes/login.tsx": $14,
"./routes/logout.ts": $15,
"./routes/signup.tsx": $16,
},
islands: {
"./islands/TodoList.tsx": $$0,
Expand Down
2 changes: 1 addition & 1 deletion import_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
"stripe": "https://esm.sh/stripe@11.13.0",
"tabler-icons/": "https://deno.land/x/tabler_icons_tsx@0.0.2/tsx/",
"@supabase/auth-helpers-shared": "https://esm.sh/@supabase/auth-helpers-shared@0.3.0",
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.10.0"
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.12.1"
}
}
4 changes: 2 additions & 2 deletions islands/TodoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ async function deleteTodo(
}

interface TodoListProps {
subscribed: boolean;
isSubscribed: boolean;
todos: Todo[];
}

export default function TodoList(props: TodoListProps) {
const todos = useSignal(props.todos);
const newTodoRef = useRef<HTMLInputElement | null>(null);

const isMoreTodos = props.subscribed ||
const isMoreTodos = props.isSubscribed ||
todos.value.length < FREE_PLAN_TODOS_LIMIT;

return (
Expand Down
12 changes: 5 additions & 7 deletions routes/api/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ export const handler: Handlers = {
const form = await request.formData();
const email = form.get("email");
const password = form.get("password");

assert(typeof email === "string");
assert(typeof password === "string");

const headers = new Headers();
const supabaseClient = createSupabaseClient(request.headers, headers);
const { error } = await supabaseClient.auth.signInWithPassword({
email,
password,
});
const { error } = await createSupabaseClient(request.headers, headers)
.auth.signInWithPassword({
email,
password,
});

let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ??
AUTHENTICATED_REDIRECT_PATH;
Expand All @@ -26,7 +25,6 @@ export const handler: Handlers = {
}

headers.set("location", redirectUrl);

return new Response(null, { headers, status: 302 });
},
};
4 changes: 2 additions & 2 deletions routes/api/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { createSupabaseClient } from "@/utils/supabase.ts";
export const handler: Handlers = {
async GET(request) {
const headers = new Headers({ location: "/" });
const supabaseClient = createSupabaseClient(request.headers, headers);
const { error } = await supabaseClient.auth.signOut();
const { error } = await createSupabaseClient(request.headers, headers)
.auth.signOut();
if (error) throw error;

return new Response(null, { headers, status: 302 });
Expand Down
19 changes: 5 additions & 14 deletions routes/api/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,21 @@ import type { Handlers } from "$fresh/server.ts";
import { AUTHENTICATED_REDIRECT_PATH } from "@/constants.ts";
import { createSupabaseClient } from "@/utils/supabase.ts";
import { assert } from "std/testing/asserts.ts";
import { stripe } from "@/utils/stripe.ts";

export const handler: Handlers = {
async POST(request) {
const form = await request.formData();
const email = form.get("email");
const password = form.get("password");

assert(typeof email === "string");
assert(typeof password === "string");

const headers = new Headers();

// 1. Create a Stripe account ready for a billing session later.
const { id } = await stripe.customers.create({ email });

// 2. Create a Supabase user with the Stripe customer ID as metadata
const supabaseClient = createSupabaseClient(request.headers, headers);
const { error } = await supabaseClient.auth.signUp({
email,
password,
options: { data: { stripe_customer_id: id } },
});
const { error } = await createSupabaseClient(request.headers, headers)
.auth.signUp({
email,
password,
});

let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ??
AUTHENTICATED_REDIRECT_PATH;
Expand All @@ -33,7 +25,6 @@ export const handler: Handlers = {
}

headers.set("location", redirectUrl);

return new Response(null, { headers, status: 302 });
},
};
81 changes: 81 additions & 0 deletions routes/api/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Handlers } from "$fresh/server.ts";
import { stripe } from "@/utils/stripe.ts";
import { Stripe } from "stripe";
import { supabaseAdminClient } from "@/utils/supabase.ts";
import type { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "@/utils/supabase_types.ts";

const cryptoProvider = Stripe.createSubtleCryptoProvider();

interface SetCustomerSubscriptionConfig {
customer: string;
isSubscribed: boolean;
}

export async function setCustomerSubscription(
supabaseClient: SupabaseClient<Database>,
{ customer, isSubscribed }: SetCustomerSubscriptionConfig,
) {
await supabaseClient
.from("customers")
.update({ is_subscribed: isSubscribed })
.eq("stripe_customer_id", customer)
.throwOnError();
}

export const handler: Handlers = {
/**
* This handler handles Stripe webhooks for the following events:
* 1. customer.subscription.created (when a user subscribes to the premium plan)
* 2. customer.subscription.deleted (when a user cancels the premium plan)
*/
async POST(request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
const signingSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!;

let event!: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
signingSecret,
undefined,
cryptoProvider,
);
} catch (error) {
console.error(error.message);
return new Response(error.message, { status: 400 });
}

switch (event.type) {
case "customer.subscription.created": {
await setCustomerSubscription(
supabaseAdminClient,
{
// @ts-ignore: Property 'customer' actually does exist on type 'Object'
customer: event.data.object.customer,
isSubscribed: true,
},
);
return new Response(null, { status: 201 });
}
case "customer.subscription.deleted": {
await setCustomerSubscription(
supabaseAdminClient,
{
// @ts-ignore: Property 'customer' actually does exist on type 'Object'
customer: event.data.object.customer,
isSubscribed: false,
},
);
return new Response(null, { status: 202 });
}
default: {
const message = `Event type not supported: ${event.type}`;
console.error(message);
return new Response(message, { status: 400 });
}
}
},
};
Loading

0 comments on commit 9e7dc71

Please sign in to comment.