Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: webhooks-based approach #16

Merged
merged 12 commits into from
Mar 31, 2023
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
iuioiua marked this conversation as resolved.
Show resolved Hide resolved

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