From 09dd3c30d717a93d2ce79ff9b07d87a411377a1a Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Sun, 26 Mar 2023 19:11:40 +1100 Subject: [PATCH 1/9] refactor: webhooks-based approach --- .example.env | 6 ++- fresh.gen.ts | 60 +++++++++++++----------- islands/TodoList.tsx | 4 +- routes/api/customer.ts | 43 +++++++++++++++++ routes/api/login.ts | 22 +++++---- routes/api/logout.ts | 4 +- routes/api/signup.ts | 27 ++++------- routes/api/subscription.ts | 54 +++++++++++++++++++++ routes/dashboard/_middleware.ts | 21 +++++++-- routes/dashboard/account.tsx | 27 +++-------- routes/dashboard/manage-subscription.ts | 2 +- routes/dashboard/todos.tsx | 19 +++----- routes/dashboard/upgrade-subscription.ts | 2 +- scripts/generate_key.ts | 10 ++++ utils/supabase.ts | 11 +++-- 15 files changed, 210 insertions(+), 102 deletions(-) create mode 100644 routes/api/customer.ts create mode 100644 routes/api/subscription.ts create mode 100644 scripts/generate_key.ts diff --git a/.example.env b/.example.env index d67f6eb8e..d1f983243 100644 --- a/.example.env +++ b/.example.env @@ -1,4 +1,8 @@ SUPABASE_ANON_KEY=xxx SUPABASE_URL=https://xxx.supabase.co +SUPABSE_SERVICE_KEY=xxx -STRIPE_SECRET_KEY=xxx \ No newline at end of file +STRIPE_SECRET_KEY=xxx +STRIPE_SIGNING_SECRET=xxx + +API_ROUTE_SECRET=xxx \ No newline at end of file diff --git a/fresh.gen.ts b/fresh.gen.ts index 317c3a6dc..c8b507cf6 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -5,40 +5,44 @@ import config from "./deno.json" assert { type: "json" }; import * as $0 from "./routes/_404.tsx"; 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 $2 from "./routes/api/customer.ts"; +import * as $3 from "./routes/api/login.ts"; +import * as $4 from "./routes/api/logout.ts"; +import * as $5 from "./routes/api/signup.ts"; +import * as $6 from "./routes/api/subscription.ts"; +import * as $7 from "./routes/dashboard/_middleware.ts"; +import * as $8 from "./routes/dashboard/account.tsx"; +import * as $9 from "./routes/dashboard/api/todo.ts"; +import * as $10 from "./routes/dashboard/index.tsx"; +import * as $11 from "./routes/dashboard/manage-subscription.ts"; +import * as $12 from "./routes/dashboard/todos.tsx"; +import * as $13 from "./routes/dashboard/upgrade-subscription.ts"; +import * as $14 from "./routes/index.tsx"; +import * as $15 from "./routes/login.tsx"; +import * as $16 from "./routes/logout.ts"; +import * as $17 from "./routes/signup.tsx"; import * as $$0 from "./islands/TodoList.tsx"; const manifest = { routes: { "./routes/_404.tsx": $0, "./routes/_500.tsx": $1, - "./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/customer.ts": $2, + "./routes/api/login.ts": $3, + "./routes/api/logout.ts": $4, + "./routes/api/signup.ts": $5, + "./routes/api/subscription.ts": $6, + "./routes/dashboard/_middleware.ts": $7, + "./routes/dashboard/account.tsx": $8, + "./routes/dashboard/api/todo.ts": $9, + "./routes/dashboard/index.tsx": $10, + "./routes/dashboard/manage-subscription.ts": $11, + "./routes/dashboard/todos.tsx": $12, + "./routes/dashboard/upgrade-subscription.ts": $13, + "./routes/index.tsx": $14, + "./routes/login.tsx": $15, + "./routes/logout.ts": $16, + "./routes/signup.tsx": $17, }, islands: { "./islands/TodoList.tsx": $$0, diff --git a/islands/TodoList.tsx b/islands/TodoList.tsx index da64d9623..049b3caab 100644 --- a/islands/TodoList.tsx +++ b/islands/TodoList.tsx @@ -50,7 +50,7 @@ async function deleteTodo( } interface TodoListProps { - subscribed: boolean; + isSubscribed: boolean; todos: Todo[]; } @@ -58,7 +58,7 @@ export default function TodoList(props: TodoListProps) { const todos = useSignal(props.todos); const newTodoName = useSignal(""); - const isMoreTodos = props.subscribed || + const isMoreTodos = props.isSubscribed || todos.value.length < FREE_PLAN_TODOS_LIMIT; return ( diff --git a/routes/api/customer.ts b/routes/api/customer.ts new file mode 100644 index 000000000..d75bce637 --- /dev/null +++ b/routes/api/customer.ts @@ -0,0 +1,43 @@ +import type { Handlers } from "$fresh/server.ts"; +import { stripe } from "@/utils/stripe.ts"; +import { supabaseAdminClient } from "@/utils/supabase.ts"; +import { assert } from "std/testing/asserts.ts"; + +/** + * Ensures that the Supabase request is authenticated based on the `API_ROUTE_SECRET` header. + * + * This function can be reused where in other Supabase webhook handlers. + */ +function isSupabaseRequestAuthenticated(request: Request) { + return new URL(request.url).searchParams.get("API_ROUTE_SECRET") === + Deno.env.get("API_ROUTE_SECRET"); +} + +export const handler: Handlers = { + /** + * This handler handles Supabase webhook when an insert event is triggered on the `users_customers` table. + * A HTTP parameter of key `API_ROUTE_SECRET` and value of that in the `.env` file must be set. + */ + async POST(request) { + if (!isSupabaseRequestAuthenticated(request)) { + await request.body?.cancel(); + return new Response(null, { status: 401 }); + } + + const { record: { user_id } } = await request.json(); + const { data } = await supabaseAdminClient.auth.admin.getUserById(user_id); + assert(data.user?.email); + + const customer = await stripe.customers.create({ + email: data.user?.email, + }); + + await supabaseAdminClient + .from("users_subscriptions") + .update({ stripe_customer_id: customer.id }) + .eq("user_id", user_id) + .throwOnError(); + + return Response.json(null, { status: 201 }); + }, +}; diff --git a/routes/api/login.ts b/routes/api/login.ts index 2b72a208e..0975dd937 100644 --- a/routes/api/login.ts +++ b/routes/api/login.ts @@ -13,20 +13,22 @@ export const handler: Handlers = { 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; + let redirectUrl: string; if (error) { redirectUrl = `/login?error=${encodeURIComponent(error.message)}`; + } else { + redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? + AUTHENTICATED_REDIRECT_PATH; } - headers.set("location", redirectUrl); - - return new Response(null, { headers, status: 302 }); + return new Response(null, { + headers: { + location: redirectUrl, + }, + status: 302, + }); }, }; diff --git a/routes/api/logout.ts b/routes/api/logout.ts index d2801fd00..b1419f95f 100644 --- a/routes/api/logout.ts +++ b/routes/api/logout.ts @@ -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 }); diff --git a/routes/api/signup.ts b/routes/api/signup.ts index 9b800ba47..18d76455c 100644 --- a/routes/api/signup.ts +++ b/routes/api/signup.ts @@ -2,7 +2,6 @@ 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) { @@ -14,26 +13,20 @@ export const handler: Handlers = { assert(typeof password === "string"); const headers = new Headers(); + const { error } = await createSupabaseClient(request.headers, headers) + .auth.signUp({ email, password }); - // 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 } }, - }); - - let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? - AUTHENTICATED_REDIRECT_PATH; + let redirectUrl: string; if (error) { redirectUrl = `/signup?error=${encodeURIComponent(error.message)}`; + } else { + redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? + AUTHENTICATED_REDIRECT_PATH; } - headers.set("location", redirectUrl); - - return new Response(null, { headers, status: 302 }); + return new Response(null, { + headers: { location: redirectUrl }, + status: 302, + }); }, }; diff --git a/routes/api/subscription.ts b/routes/api/subscription.ts new file mode 100644 index 000000000..b181efb02 --- /dev/null +++ b/routes/api/subscription.ts @@ -0,0 +1,54 @@ +import type { Handlers } from "$fresh/server.ts"; +import { stripe } from "@/utils/stripe.ts"; +import { Stripe } from "stripe"; +import { supabaseAdminClient } from "@/utils/supabase.ts"; + +const cryptoProvider = Stripe.createSubtleCryptoProvider(); + +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) + * + * @todo Create another subscription plan and implement `customer.subscription.deleted` event type. + */ + async POST(request) { + const body = await request.text(); + const signature = request.headers.get("stripe-signature")!; + const signingSecret = Deno.env.get("STRIPE_SIGNING_SECRET")!; + + const event = await stripe.webhooks.constructEventAsync( + body, + signature, + signingSecret, + undefined, + cryptoProvider, + ); + + switch (event.type) { + case "customer.subscription.created": { + await supabaseAdminClient + .from("users_subscriptions") + .update({ is_subscribed: true }) + // @ts-ignore: Property 'customer' actually does exist on type 'Object' + .eq("stripe_customer_id", event.data.object.customer) + .throwOnError(); + return new Response(null, { status: 201 }); + } + case "customer.subscription.deleted": { + await supabaseAdminClient + .from("users_subscriptions") + .update({ is_subscribed: false }) + // @ts-ignore: Property 'customer' actually does exist on type 'Object' + .eq("stripe_customer_id", event.data.object.customer) + .throwOnError(); + return new Response(null, { status: 202 }); + } + default: { + console.error(`Event type not supported: ${event.type}`); + return new Response(null, { status: 400 }); + } + } + }, +}; diff --git a/routes/dashboard/_middleware.ts b/routes/dashboard/_middleware.ts index 5f04316fa..949ef61f3 100644 --- a/routes/dashboard/_middleware.ts +++ b/routes/dashboard/_middleware.ts @@ -5,6 +5,10 @@ import type { Session } from "@supabase/supabase-js"; export interface DashboardState { session: Session; + subscription: { + stripeCustomerId: string; + isSubscribed: boolean; + }; } export function getLoginPath(redirectUrl: string) { @@ -19,9 +23,20 @@ export async function handler( try { const headers = new Headers(); const supabaseClient = createSupabaseClient(request.headers, headers); - const { data } = await supabaseClient.auth.getSession(); - assert(data.session); - ctx.state.session = data.session; + + const { data: { session } } = await supabaseClient.auth.getSession(); + assert(session); + ctx.state.session = session; + + const { data } = await supabaseClient + .from("users_subscriptions") + .select("stripe_customer_id, is_subscribed") + .single() + .throwOnError(); + ctx.state.subscription = { + stripeCustomerId: data?.stripe_customer_id, + isSubscribed: data?.is_subscribed, + }; const response = await ctx.next(); /** diff --git a/routes/dashboard/account.tsx b/routes/dashboard/account.tsx index 41fec2fc7..a10219805 100644 --- a/routes/dashboard/account.tsx +++ b/routes/dashboard/account.tsx @@ -1,31 +1,16 @@ import type { Handlers, PageProps } from "$fresh/server.ts"; import Head from "@/components/Head.tsx"; -import type { User } from "@supabase/supabase-js"; import type { DashboardState } from "./_middleware.ts"; import Dashboard from "@/components/Dashboard.tsx"; -import { stripe } from "@/utils/stripe.ts"; -interface PageData { - subscribed: boolean; - user: User; -} - -export const handler: Handlers = { - async GET(_request, ctx) { - const { user } = ctx.state.session; - const { data: [subscription] } = await stripe.subscriptions.list({ - customer: user.user_metadata.stripe_customer_id, - }); - - return await ctx.render({ - subscribed: Boolean(subscription), - user: ctx.state.session.user, - }); +export const handler: Handlers = { + GET(_request, ctx) { + return ctx.render(ctx.state); }, }; -export default function AccountPage(props: PageProps) { - const action = props.data.subscribed ? "Manage" : "Upgrade"; +export default function AccountPage(props: PageProps) { + const action = props.data.subscription.isSubscribed ? "Manage" : "Upgrade"; return ( <> @@ -36,7 +21,7 @@ export default function AccountPage(props: PageProps) {
Email
-
{props.data.user.email}
+
{props.data.session.user.email}
  • diff --git a/routes/dashboard/manage-subscription.ts b/routes/dashboard/manage-subscription.ts index 7b5eb0b45..c2e37616d 100644 --- a/routes/dashboard/manage-subscription.ts +++ b/routes/dashboard/manage-subscription.ts @@ -6,7 +6,7 @@ import { DashboardState } from "./_middleware.ts"; export const handler: Handlers = { async GET(request, ctx) { const { url } = await stripe.billingPortal.sessions.create({ - customer: ctx.state.session.user.user_metadata.stripe_customer_id, + customer: ctx.state.subscription.stripeCustomerId, return_url: new URL(request.url).origin + "/dashboard", }); diff --git a/routes/dashboard/todos.tsx b/routes/dashboard/todos.tsx index 581b256df..a36eef35b 100644 --- a/routes/dashboard/todos.tsx +++ b/routes/dashboard/todos.tsx @@ -1,6 +1,5 @@ import type { Handlers, PageProps } from "$fresh/server.ts"; import { getTodos, type Todo } from "@/utils/todos.ts"; -import { stripe } from "@/utils/stripe.ts"; import Head from "@/components/Head.tsx"; import TodoList from "@/islands/TodoList.tsx"; import Notice from "@/components/Notice.tsx"; @@ -8,21 +7,17 @@ import { DashboardState } from "./_middleware.ts"; import Dashboard from "@/components/Dashboard.tsx"; import { createSupabaseClient } from "../../utils/supabase.ts"; -interface Data { - subscribed: boolean; +interface Data extends DashboardState { todos: Todo[]; } export const handler: Handlers = { async GET(request, ctx) { - const { user } = ctx.state.session; - const { data: [subscription] } = await stripe.subscriptions.list({ - customer: user.user_metadata.stripe_customer_id, - }); + const todos = await getTodos(createSupabaseClient(request.headers)); - return await ctx.render({ - subscribed: Boolean(subscription), - todos: await getTodos(createSupabaseClient(request.headers)), + return ctx.render({ + ...ctx.state, + todos, }); }, }; @@ -32,7 +27,7 @@ export default function TodosPage(props: PageProps) { <> - {!props.data.subscribed && ( + {!props.data.subscription.isSubscribed && ( ) { /> )} diff --git a/routes/dashboard/upgrade-subscription.ts b/routes/dashboard/upgrade-subscription.ts index adf8ef29a..a34120244 100644 --- a/routes/dashboard/upgrade-subscription.ts +++ b/routes/dashboard/upgrade-subscription.ts @@ -8,7 +8,7 @@ export const handler: Handlers = { async GET(request, ctx) { const { url } = await stripe.checkout.sessions.create({ success_url: new URL(request.url).origin + "/dashboard/todos", - customer: ctx.state.session.user.user_metadata.stripe_customer_id, + customer: ctx.state.subscription.stripeCustomerId, line_items: [ { price: STRIPE_PREMIUM_PLAN_PRICE_ID, diff --git a/scripts/generate_key.ts b/scripts/generate_key.ts new file mode 100644 index 000000000..134e73a77 --- /dev/null +++ b/scripts/generate_key.ts @@ -0,0 +1,10 @@ +/** This script can be used to generate the value for the 'API_ROUTE_SECRET' environment variable. */ +import { toHashString } from "std/crypto/to_hash_string.ts"; + +const BYTE_LENGTH = 32; + +const bytes = new Uint32Array(BYTE_LENGTH); +crypto.getRandomValues(bytes); +const key = toHashString(bytes); + +console.log(key); diff --git a/utils/supabase.ts b/utils/supabase.ts index 995ea79d2..f3be6152f 100644 --- a/utils/supabase.ts +++ b/utils/supabase.ts @@ -1,13 +1,10 @@ import type { Database } from "./supabase_types.ts"; import { createServerSupabaseClient } from "@supabase/auth-helpers-shared"; import { getCookies, setCookie } from "std/http/cookie.ts"; +import { createClient } from "@supabase/supabase-js"; export type SupabaseClient = ReturnType; -export function hasSupabaseAuthToken(headers: Headers) { - return Boolean(getCookies(headers)["supabase-auth-token"]); -} - export function createSupabaseClient( requestHeaders: Headers, responseHeaders?: Headers, @@ -33,3 +30,9 @@ export function createSupabaseClient( }, }); } + +// Required to bypass Row Level Security (RLS) +export const supabaseAdminClient = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABSE_SERVICE_KEY")!, +); From b810e11e1f8a8014893c31d7b2d9fafe55ed01c5 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 27 Mar 2023 17:10:03 +1100 Subject: [PATCH 2/9] revert --- routes/api/login.ts | 22 ++++++++++------------ routes/api/logout.ts | 4 ++-- routes/api/signup.ts | 21 +++++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/routes/api/login.ts b/routes/api/login.ts index 0975dd937..2b72a208e 100644 --- a/routes/api/login.ts +++ b/routes/api/login.ts @@ -13,22 +13,20 @@ export const handler: Handlers = { assert(typeof password === "string"); const headers = new Headers(); - const { error } = await createSupabaseClient(request.headers, headers) - .auth.signInWithPassword({ email, password }); + const supabaseClient = createSupabaseClient(request.headers, headers); + const { error } = await supabaseClient.auth.signInWithPassword({ + email, + password, + }); - let redirectUrl: string; + let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? + AUTHENTICATED_REDIRECT_PATH; if (error) { redirectUrl = `/login?error=${encodeURIComponent(error.message)}`; - } else { - redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? - AUTHENTICATED_REDIRECT_PATH; } - return new Response(null, { - headers: { - location: redirectUrl, - }, - status: 302, - }); + headers.set("location", redirectUrl); + + return new Response(null, { headers, status: 302 }); }, }; diff --git a/routes/api/logout.ts b/routes/api/logout.ts index b1419f95f..d2801fd00 100644 --- a/routes/api/logout.ts +++ b/routes/api/logout.ts @@ -4,8 +4,8 @@ import { createSupabaseClient } from "@/utils/supabase.ts"; export const handler: Handlers = { async GET(request) { const headers = new Headers({ location: "/" }); - const { error } = await createSupabaseClient(request.headers, headers) - .auth.signOut(); + const supabaseClient = createSupabaseClient(request.headers, headers); + const { error } = await supabaseClient.auth.signOut(); if (error) throw error; return new Response(null, { headers, status: 302 }); diff --git a/routes/api/signup.ts b/routes/api/signup.ts index 18d76455c..bf94b4fef 100644 --- a/routes/api/signup.ts +++ b/routes/api/signup.ts @@ -13,20 +13,21 @@ export const handler: Handlers = { assert(typeof password === "string"); const headers = new Headers(); - const { error } = await createSupabaseClient(request.headers, headers) - .auth.signUp({ email, password }); - let redirectUrl: string; + const supabaseClient = createSupabaseClient(request.headers, headers); + const { error } = await supabaseClient.auth.signUp({ + email, + password, + }); + + let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? + AUTHENTICATED_REDIRECT_PATH; if (error) { redirectUrl = `/signup?error=${encodeURIComponent(error.message)}`; - } else { - redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? - AUTHENTICATED_REDIRECT_PATH; } - return new Response(null, { - headers: { location: redirectUrl }, - status: 302, - }); + headers.set("location", redirectUrl); + + return new Response(null, { headers, status: 302 }); }, }; From 73518fc626d9898b73bca2b1653321af24e22f5a Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 27 Mar 2023 17:10:54 +1100 Subject: [PATCH 3/9] fix --- routes/api/customer.ts | 2 +- routes/api/subscription.ts | 4 ++-- routes/dashboard/_middleware.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/api/customer.ts b/routes/api/customer.ts index d75bce637..aaf0abfa0 100644 --- a/routes/api/customer.ts +++ b/routes/api/customer.ts @@ -33,7 +33,7 @@ export const handler: Handlers = { }); await supabaseAdminClient - .from("users_subscriptions") + .from("subscriptions") .update({ stripe_customer_id: customer.id }) .eq("user_id", user_id) .throwOnError(); diff --git a/routes/api/subscription.ts b/routes/api/subscription.ts index b181efb02..7d2567667 100644 --- a/routes/api/subscription.ts +++ b/routes/api/subscription.ts @@ -29,7 +29,7 @@ export const handler: Handlers = { switch (event.type) { case "customer.subscription.created": { await supabaseAdminClient - .from("users_subscriptions") + .from("subscriptions") .update({ is_subscribed: true }) // @ts-ignore: Property 'customer' actually does exist on type 'Object' .eq("stripe_customer_id", event.data.object.customer) @@ -38,7 +38,7 @@ export const handler: Handlers = { } case "customer.subscription.deleted": { await supabaseAdminClient - .from("users_subscriptions") + .from("subscriptions") .update({ is_subscribed: false }) // @ts-ignore: Property 'customer' actually does exist on type 'Object' .eq("stripe_customer_id", event.data.object.customer) diff --git a/routes/dashboard/_middleware.ts b/routes/dashboard/_middleware.ts index 60c84b906..7b1e96af6 100644 --- a/routes/dashboard/_middleware.ts +++ b/routes/dashboard/_middleware.ts @@ -29,7 +29,7 @@ export async function handler( ctx.state.session = session; const { data } = await supabaseClient - .from("users_subscriptions") + .from("subscriptions") .select("stripe_customer_id, is_subscribed") .single() .throwOnError(); From f3266438bc89abef49f716c75bb4767e959d6ba6 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Tue, 28 Mar 2023 19:01:32 +1100 Subject: [PATCH 4/9] further instructions --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f4cf87e50..48af764ca 100644 --- a/README.md +++ b/README.md @@ -70,20 +70,78 @@ like. But to get this Deno SaaSKit template to work, we'll create a `todos` table. -- Go to `Databases` > `Tables` -- Click `New Table` -- Enter the name as `todos` and check `Enable Row Level Security (RLS)` -- Configure the following columns: - -| Name | Type | Default value | Primary | -| --------- | ------ | -------------------- | ------- | -| `id` | `int8` | -- | `true` | -| `name` | `text` | (empty) | `false` | -| `user_id` | `uuid` | `uuid_generate_v4()` | `false` | +Copy the following snippet and paste it into the SQL editor as a new snippet: + +```sql +create table todos ( + id bigint generated by default as identity primary key, + user_id uuid default auth.uid() references auth.users on delete cascade not null, + name text + ); +alter table + todos enable row level security; +create policy "Enable all operations for users based on user_id" on todos using ( + ( + auth.uid() = user_id + ) +); +``` -You can also keep the column `created_at` if you'd like. +### Create a `subscriptions` table + +Copy the following snippet and paste the resulting string into the SQL editor as +a new query. + +```sql +create table subscriptions ( + user_id uuid default auth.uid() references auth.users on delete cascade not null primary key, + stripe_customer_id text, + is_subscribed boolean default false not null +); +alter table + subscriptions enable row level security; +create policy "Enable all operations for users based on user_id" on subscriptions using ( + ( + auth.uid() = user_id + ) +); +create function create_subscription() returns trigger language plpgsql security definer as $$begin insert into subscriptions(user_id) +values + (new.id); +return new; +end; +$$; +create trigger create_stripe_customer +after + insert on subscriptions for each row EXECUTE FUNCTION supabase_functions.http_request( + 'https:///api/customer', + 'POST', '{"Content-type":"application/json"}', + '{"API_ROUTE_SECRET":""}', + '1000' + ); +``` -Hit save and then your table should be created. +Next, [create a new webhook](https://app.supabase.com/project/_/database/hooks) +with the following values: + +- Name = `create_stripe_customer` +- Table = `subscriptions` +- Event = `Insert` +- Type of hook = `HTTP Request` +- Method = `POST` +- URL = `https:///api/customer` +- HTTP Parameters = `API_ROUTE_SECRET` for the parameter name and the value of + the `API_ROUTE_SECRET` environment variable for the parameter value. + +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:///api/subscription` +- Listen to `Events on your account` +- Select events to listen to: + - `customer.subscription.created` + - `customer.subscription.deleted` ### Setup authentication From b982d56ca5baabbbda16b7d23e80f59cdf627d94 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Wed, 29 Mar 2023 14:06:38 +1100 Subject: [PATCH 5/9] cleanups --- routes/api/customer.ts | 22 ++++++++-------- routes/api/subscription.ts | 52 +++++++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/routes/api/customer.ts b/routes/api/customer.ts index aaf0abfa0..5be6bca89 100644 --- a/routes/api/customer.ts +++ b/routes/api/customer.ts @@ -1,42 +1,44 @@ import type { Handlers } from "$fresh/server.ts"; import { stripe } from "@/utils/stripe.ts"; import { supabaseAdminClient } from "@/utils/supabase.ts"; -import { assert } from "std/testing/asserts.ts"; /** * Ensures that the Supabase request is authenticated based on the `API_ROUTE_SECRET` header. * * This function can be reused where in other Supabase webhook handlers. */ -function isSupabaseRequestAuthenticated(request: Request) { +function hasRouteSecret(request: Request) { return new URL(request.url).searchParams.get("API_ROUTE_SECRET") === Deno.env.get("API_ROUTE_SECRET"); } +async function setStripeCustomerId(userId: string, stripeCustomerId: string) { + await supabaseAdminClient + .from("subscriptions") + .update({ stripe_customer_id: stripeCustomerId }) + .eq("user_id", userId) + .throwOnError(); +} + export const handler: Handlers = { /** * This handler handles Supabase webhook when an insert event is triggered on the `users_customers` table. * A HTTP parameter of key `API_ROUTE_SECRET` and value of that in the `.env` file must be set. */ async POST(request) { - if (!isSupabaseRequestAuthenticated(request)) { + if (!hasRouteSecret(request)) { await request.body?.cancel(); return new Response(null, { status: 401 }); } const { record: { user_id } } = await request.json(); const { data } = await supabaseAdminClient.auth.admin.getUserById(user_id); - assert(data.user?.email); const customer = await stripe.customers.create({ - email: data.user?.email, + email: data.user!.email, }); - await supabaseAdminClient - .from("subscriptions") - .update({ stripe_customer_id: customer.id }) - .eq("user_id", user_id) - .throwOnError(); + await setStripeCustomerId(user_id, customer.id); return Response.json(null, { status: 201 }); }, diff --git a/routes/api/subscription.ts b/routes/api/subscription.ts index 7d2567667..e5f8e1cd3 100644 --- a/routes/api/subscription.ts +++ b/routes/api/subscription.ts @@ -5,6 +5,17 @@ import { supabaseAdminClient } from "@/utils/supabase.ts"; const cryptoProvider = Stripe.createSubtleCryptoProvider(); +async function changeCustomerSubscription( + customer: string, + isSubscribed: boolean, +) { + await supabaseAdminClient + .from("subscriptions") + .update({ is_subscribed: isSubscribed }) + .eq("stripe_customer_id", customer) + .throwOnError(); +} + export const handler: Handlers = { /** * This handler handles Stripe webhooks for the following events: @@ -18,36 +29,35 @@ export const handler: Handlers = { const signature = request.headers.get("stripe-signature")!; const signingSecret = Deno.env.get("STRIPE_SIGNING_SECRET")!; - const event = await stripe.webhooks.constructEventAsync( - body, - signature, - signingSecret, - undefined, - cryptoProvider, - ); + 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 supabaseAdminClient - .from("subscriptions") - .update({ is_subscribed: true }) - // @ts-ignore: Property 'customer' actually does exist on type 'Object' - .eq("stripe_customer_id", event.data.object.customer) - .throwOnError(); + // @ts-ignore: Property 'customer' actually does exist on type 'Object' + await changeCustomerSubscription(event.data.object.customer, true); return new Response(null, { status: 201 }); } case "customer.subscription.deleted": { - await supabaseAdminClient - .from("subscriptions") - .update({ is_subscribed: false }) - // @ts-ignore: Property 'customer' actually does exist on type 'Object' - .eq("stripe_customer_id", event.data.object.customer) - .throwOnError(); + // @ts-ignore: Property 'customer' actually does exist on type 'Object' + await changeCustomerSubscription(event.data.object.customer, false); return new Response(null, { status: 202 }); } default: { - console.error(`Event type not supported: ${event.type}`); - return new Response(null, { status: 400 }); + const message = `Event type not supported: ${event.type}`; + console.error(message); + return new Response(message, { status: 400 }); } } }, From 1616f06c745d41a474bd70afd049db69d30a21e4 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Thu, 30 Mar 2023 15:54:25 +1100 Subject: [PATCH 6/9] fixes, tweaks and documentation --- README.md | 138 ++++++++++++++++++-------------- routes/api/customer.ts | 21 ++++- routes/api/login.ts | 12 ++- routes/api/logout.ts | 4 +- routes/api/signup.ts | 13 ++- routes/api/subscription.ts | 39 ++++++--- routes/dashboard/_middleware.ts | 11 +-- 7 files changed, 138 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 3acfe1b99..c18ee5467 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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: @@ -8,7 +8,7 @@ these foundational features that every SaaS needs: - User creation flows - Landing page - Pricing section -- Signin and session management +- Sign-in and session management - Billing integration via Stripe - Gated API endpoints @@ -52,7 +52,10 @@ The only variables you need are: - `SUPABASE_ANON_KEY` - `SUPABASE_URL` +- `SUPABSE_SERVICE_KEY` - `STRIPE_SECRET_KEY` +- `STRIPE_SIGNING_SECRET` +- `API_ROUTE_SECRET` Continue below to learn where to grab these keys. @@ -73,7 +76,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 @@ -85,68 +88,84 @@ like. But to get this Deno SaaSKit template to work, we'll create a `todos` table in the [Supabase Dashboard](http://localhost:54323/project/default/editor). -Copy the following snippet and paste it into the SQL editor as a new snippet: +- Go to `Databases` > `Tables` +- Click `New Table` +- Enter the name as `todos` and check `Enable Row Level Security (RLS)` +- Configure the following columns: + +| Name | Type | Default value | Primary | +| --------- | ------ | -------------------- | ------- | +| `id` | `uuid` | `uuid_generate_v4()` | `true` | +| `name` | `text` | `NULL` | `false` | +| `user_id` | `uuid` | `auth.uid()` | `false` | + +We want the `user_id` column to link back to our Supabase Auth users, this is +what allows us to write security policies to restrict access to the data based +on the authenticated user. To set up this reference, 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. + +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 with automattic population + +- 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. +- Next, go `Database` > `Functions` and click `Create a new function` with the + following values: + - Name of function = `create_customer` + - Schema = `public` + - Return type = `trigger` + - Definition = ```sql -create table todos ( - id bigint generated by default as identity primary key, - user_id uuid default auth.uid() references auth.users on delete cascade not null, - name text - ); -alter table - todos enable row level security; -create policy "Enable all operations for users based on user_id" on todos using ( - ( - auth.uid() = user_id - ) -); +begin + insert into public.customers(user_id) + values (new.id); + return new; +end; ``` -### Create a `subscriptions` table - -Copy the following snippet and paste the resulting string into the SQL editor as -a new query. +- Click `Show advanced settings` and select `SECURITY DEFINER` under + `Type of security` +- Click `Confirm` +- Go to `Database` > `Triggers` +- Click `Create a new trigger` and enter the following values: + - Name of trigger = `new_customer` + - Table = `users auth` + - Events = `Insert` + - Trigger type = `After the event` + - Orientation = `Row` + - Function to trigger = `create_customer` -```sql -create table subscriptions ( - user_id uuid default auth.uid() references auth.users on delete cascade not null primary key, - stripe_customer_id text, - is_subscribed boolean default false not null -); -alter table - subscriptions enable row level security; -create policy "Enable all operations for users based on user_id" on subscriptions using ( - ( - auth.uid() = user_id - ) -); -create function create_subscription() returns trigger language plpgsql security definer as $$begin insert into subscriptions(user_id) -values - (new.id); -return new; -end; -$$; -create trigger create_stripe_customer -after - insert on subscriptions for each row EXECUTE FUNCTION supabase_functions.http_request( - 'https:///api/customer', - 'POST', '{"Content-type":"application/json"}', - '{"API_ROUTE_SECRET":""}', - '1000' - ); -``` +### Automate Stripe subscription updates via Supabase Next, [create a new webhook](https://app.supabase.com/project/_/database/hooks) with the following values: - Name = `create_stripe_customer` -- Table = `subscriptions` +- Table = `customers` - Event = `Insert` - Type of hook = `HTTP Request` - Method = `POST` - URL = `https:///api/customer` - HTTP Parameters = `API_ROUTE_SECRET` for the parameter name and the value of the `API_ROUTE_SECRET` environment variable for the parameter value. +- Click `Create webhook` In Stripe, register a webhook endpoint by following [this guide](https://stripe.com/docs/development/dashboard/register-webhook) @@ -171,16 +190,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. @@ -189,7 +210,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 @@ -211,13 +232,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 @@ -263,7 +279,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 diff --git a/routes/api/customer.ts b/routes/api/customer.ts index 5be6bca89..92545a107 100644 --- a/routes/api/customer.ts +++ b/routes/api/customer.ts @@ -1,6 +1,8 @@ import type { Handlers } from "$fresh/server.ts"; import { stripe } from "@/utils/stripe.ts"; import { supabaseAdminClient } from "@/utils/supabase.ts"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { Database } from "@/utils/supabase_types.ts"; /** * Ensures that the Supabase request is authenticated based on the `API_ROUTE_SECRET` header. @@ -12,9 +14,17 @@ function hasRouteSecret(request: Request) { Deno.env.get("API_ROUTE_SECRET"); } -async function setStripeCustomerId(userId: string, stripeCustomerId: string) { - await supabaseAdminClient - .from("subscriptions") +interface SetStripeCustomerIdConfig { + userId: string; + stripeCustomerId: string; +} + +export async function setStripeCustomerId( + supabaseClient: SupabaseClient, + { userId, stripeCustomerId }: SetStripeCustomerIdConfig, +) { + await supabaseClient + .from("customers") .update({ stripe_customer_id: stripeCustomerId }) .eq("user_id", userId) .throwOnError(); @@ -38,7 +48,10 @@ export const handler: Handlers = { email: data.user!.email, }); - await setStripeCustomerId(user_id, customer.id); + await setStripeCustomerId(supabaseAdminClient, { + userId: user_id, + stripeCustomerId: customer.id, + }); return Response.json(null, { status: 201 }); }, diff --git a/routes/api/login.ts b/routes/api/login.ts index 2b72a208e..295964753 100644 --- a/routes/api/login.ts +++ b/routes/api/login.ts @@ -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; @@ -26,7 +25,6 @@ export const handler: Handlers = { } headers.set("location", redirectUrl); - return new Response(null, { headers, status: 302 }); }, }; diff --git a/routes/api/logout.ts b/routes/api/logout.ts index d2801fd00..b1419f95f 100644 --- a/routes/api/logout.ts +++ b/routes/api/logout.ts @@ -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 }); diff --git a/routes/api/signup.ts b/routes/api/signup.ts index bf94b4fef..b07b686f7 100644 --- a/routes/api/signup.ts +++ b/routes/api/signup.ts @@ -8,17 +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.signUp({ - email, - password, - }); + const { error } = await createSupabaseClient(request.headers, headers) + .auth.signUp({ + email, + password, + }); let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? AUTHENTICATED_REDIRECT_PATH; @@ -27,7 +25,6 @@ export const handler: Handlers = { } headers.set("location", redirectUrl); - return new Response(null, { headers, status: 302 }); }, }; diff --git a/routes/api/subscription.ts b/routes/api/subscription.ts index e5f8e1cd3..26561ab7b 100644 --- a/routes/api/subscription.ts +++ b/routes/api/subscription.ts @@ -2,15 +2,22 @@ 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(); -async function changeCustomerSubscription( - customer: string, - isSubscribed: boolean, +interface SetCustomerSubscriptionConfig { + customer: string; + isSubscribed: boolean; +} + +export async function setCustomerSubscription( + supabaseClient: SupabaseClient, + { customer, isSubscribed }: SetCustomerSubscriptionConfig, ) { - await supabaseAdminClient - .from("subscriptions") + await supabaseClient + .from("customers") .update({ is_subscribed: isSubscribed }) .eq("stripe_customer_id", customer) .throwOnError(); @@ -21,8 +28,6 @@ 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) - * - * @todo Create another subscription plan and implement `customer.subscription.deleted` event type. */ async POST(request) { const body = await request.text(); @@ -45,13 +50,25 @@ export const handler: Handlers = { switch (event.type) { case "customer.subscription.created": { - // @ts-ignore: Property 'customer' actually does exist on type 'Object' - await changeCustomerSubscription(event.data.object.customer, true); + 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": { - // @ts-ignore: Property 'customer' actually does exist on type 'Object' - await changeCustomerSubscription(event.data.object.customer, false); + 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: { diff --git a/routes/dashboard/_middleware.ts b/routes/dashboard/_middleware.ts index 7b1e96af6..26b165bcf 100644 --- a/routes/dashboard/_middleware.ts +++ b/routes/dashboard/_middleware.ts @@ -29,19 +29,16 @@ export async function handler( ctx.state.session = session; const { data } = await supabaseClient - .from("subscriptions") + .from("customers") .select("stripe_customer_id, is_subscribed") .single() .throwOnError(); + ctx.state.subscription = { - stripeCustomerId: data?.stripe_customer_id, - isSubscribed: data?.is_subscribed, + stripeCustomerId: data!.stripe_customer_id, + isSubscribed: data!.is_subscribed, }; - // Throws if session is `null`, causing a redirect to the login page. - assert(session); - - ctx.state.session = session; const response = await ctx.next(); /** * Note: ensure that a `new Response()` with a `location` header is used when performing server-side redirects. From d638ff70a2efa525524fbc3ced9d221e4c7b23a6 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Thu, 30 Mar 2023 19:06:30 +1100 Subject: [PATCH 7/9] change: STRIPE_WEBHOOK_SECRET --- .example.env | 2 +- README.md | 2 +- routes/api/subscription.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.example.env b/.example.env index 4c756b20d..d0899a5a3 100644 --- a/.example.env +++ b/.example.env @@ -3,6 +3,6 @@ SUPABASE_URL=http://localhost:54321 SUPABSE_SERVICE_KEY=xxx STRIPE_SECRET_KEY=sk_test_xxx -STRIPE_SIGNING_SECRET=xxx +STRIPE_WEBHOOK_SECRET=xxx API_ROUTE_SECRET=xxx \ No newline at end of file diff --git a/README.md b/README.md index c18ee5467..e75f7713d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The only variables you need are: - `SUPABASE_URL` - `SUPABSE_SERVICE_KEY` - `STRIPE_SECRET_KEY` -- `STRIPE_SIGNING_SECRET` +- `STRIPE_WEBHOOK_SECRET` - `API_ROUTE_SECRET` Continue below to learn where to grab these keys. diff --git a/routes/api/subscription.ts b/routes/api/subscription.ts index 26561ab7b..c435a6639 100644 --- a/routes/api/subscription.ts +++ b/routes/api/subscription.ts @@ -32,7 +32,7 @@ export const handler: Handlers = { async POST(request) { const body = await request.text(); const signature = request.headers.get("stripe-signature")!; - const signingSecret = Deno.env.get("STRIPE_SIGNING_SECRET")!; + const signingSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!; let event!: Stripe.Event; try { From 37fbf16379d0f5acffcc4136961320df0f52bdbe Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Fri, 31 Mar 2023 08:07:41 +1100 Subject: [PATCH 8/9] remove customers-table-related webhooks --- .example.env | 4 +- README.md | 42 +--------------- fresh.gen.ts | 62 ++++++++++++------------ import_map.json | 2 +- routes/api/customer.ts | 58 ---------------------- routes/dashboard/_middleware.ts | 26 ++++------ routes/dashboard/account.tsx | 2 +- routes/dashboard/api/todo.ts | 14 +++--- routes/dashboard/manage-subscription.ts | 2 +- routes/dashboard/todos.tsx | 16 +++--- routes/dashboard/upgrade-subscription.ts | 5 +- scripts/generate_key.ts | 10 ---- utils/supabase.ts | 43 ++++++++++++++-- utils/supabase_types.ts | 34 +++++++++---- 14 files changed, 123 insertions(+), 197 deletions(-) delete mode 100644 routes/api/customer.ts delete mode 100644 scripts/generate_key.ts diff --git a/.example.env b/.example.env index d0899a5a3..5894bf9ea 100644 --- a/.example.env +++ b/.example.env @@ -3,6 +3,4 @@ SUPABASE_URL=http://localhost:54321 SUPABSE_SERVICE_KEY=xxx STRIPE_SECRET_KEY=sk_test_xxx -STRIPE_WEBHOOK_SECRET=xxx - -API_ROUTE_SECRET=xxx \ No newline at end of file +STRIPE_WEBHOOK_SECRET=xxx \ No newline at end of file diff --git a/README.md b/README.md index e75f7713d..5f1338c8c 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ The only variables you need are: - `SUPABSE_SERVICE_KEY` - `STRIPE_SECRET_KEY` - `STRIPE_WEBHOOK_SECRET` -- `API_ROUTE_SECRET` Continue below to learn where to grab these keys. @@ -109,7 +108,7 @@ 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 with automattic population +### Create a `customers` table - Go to `Database` > `Tables` - Click `New Table` @@ -125,48 +124,9 @@ Hit save and then your table should be created. - 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. -- Next, go `Database` > `Functions` and click `Create a new function` with the - following values: - - Name of function = `create_customer` - - Schema = `public` - - Return type = `trigger` - - Definition = - -```sql -begin - insert into public.customers(user_id) - values (new.id); - return new; -end; -``` - -- Click `Show advanced settings` and select `SECURITY DEFINER` under - `Type of security` -- Click `Confirm` -- Go to `Database` > `Triggers` -- Click `Create a new trigger` and enter the following values: - - Name of trigger = `new_customer` - - Table = `users auth` - - Events = `Insert` - - Trigger type = `After the event` - - Orientation = `Row` - - Function to trigger = `create_customer` ### Automate Stripe subscription updates via Supabase -Next, [create a new webhook](https://app.supabase.com/project/_/database/hooks) -with the following values: - -- Name = `create_stripe_customer` -- Table = `customers` -- Event = `Insert` -- Type of hook = `HTTP Request` -- Method = `POST` -- URL = `https:///api/customer` -- HTTP Parameters = `API_ROUTE_SECRET` for the parameter name and the value of - the `API_ROUTE_SECRET` environment variable for the parameter value. -- Click `Create webhook` - In Stripe, register a webhook endpoint by following [this guide](https://stripe.com/docs/development/dashboard/register-webhook) with the following values: diff --git a/fresh.gen.ts b/fresh.gen.ts index c8b507cf6..c6c8ee622 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -5,44 +5,42 @@ import config from "./deno.json" assert { type: "json" }; import * as $0 from "./routes/_404.tsx"; import * as $1 from "./routes/_500.tsx"; -import * as $2 from "./routes/api/customer.ts"; -import * as $3 from "./routes/api/login.ts"; -import * as $4 from "./routes/api/logout.ts"; -import * as $5 from "./routes/api/signup.ts"; -import * as $6 from "./routes/api/subscription.ts"; -import * as $7 from "./routes/dashboard/_middleware.ts"; -import * as $8 from "./routes/dashboard/account.tsx"; -import * as $9 from "./routes/dashboard/api/todo.ts"; -import * as $10 from "./routes/dashboard/index.tsx"; -import * as $11 from "./routes/dashboard/manage-subscription.ts"; -import * as $12 from "./routes/dashboard/todos.tsx"; -import * as $13 from "./routes/dashboard/upgrade-subscription.ts"; -import * as $14 from "./routes/index.tsx"; -import * as $15 from "./routes/login.tsx"; -import * as $16 from "./routes/logout.ts"; -import * as $17 from "./routes/signup.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/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 = { routes: { "./routes/_404.tsx": $0, "./routes/_500.tsx": $1, - "./routes/api/customer.ts": $2, - "./routes/api/login.ts": $3, - "./routes/api/logout.ts": $4, - "./routes/api/signup.ts": $5, - "./routes/api/subscription.ts": $6, - "./routes/dashboard/_middleware.ts": $7, - "./routes/dashboard/account.tsx": $8, - "./routes/dashboard/api/todo.ts": $9, - "./routes/dashboard/index.tsx": $10, - "./routes/dashboard/manage-subscription.ts": $11, - "./routes/dashboard/todos.tsx": $12, - "./routes/dashboard/upgrade-subscription.ts": $13, - "./routes/index.tsx": $14, - "./routes/login.tsx": $15, - "./routes/logout.ts": $16, - "./routes/signup.tsx": $17, + "./routes/api/login.ts": $2, + "./routes/api/logout.ts": $3, + "./routes/api/signup.ts": $4, + "./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, diff --git a/import_map.json b/import_map.json index 143e96dce..a305d25ae 100644 --- a/import_map.json +++ b/import_map.json @@ -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" } } diff --git a/routes/api/customer.ts b/routes/api/customer.ts deleted file mode 100644 index 92545a107..000000000 --- a/routes/api/customer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Handlers } from "$fresh/server.ts"; -import { stripe } from "@/utils/stripe.ts"; -import { supabaseAdminClient } from "@/utils/supabase.ts"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import { Database } from "@/utils/supabase_types.ts"; - -/** - * Ensures that the Supabase request is authenticated based on the `API_ROUTE_SECRET` header. - * - * This function can be reused where in other Supabase webhook handlers. - */ -function hasRouteSecret(request: Request) { - return new URL(request.url).searchParams.get("API_ROUTE_SECRET") === - Deno.env.get("API_ROUTE_SECRET"); -} - -interface SetStripeCustomerIdConfig { - userId: string; - stripeCustomerId: string; -} - -export async function setStripeCustomerId( - supabaseClient: SupabaseClient, - { userId, stripeCustomerId }: SetStripeCustomerIdConfig, -) { - await supabaseClient - .from("customers") - .update({ stripe_customer_id: stripeCustomerId }) - .eq("user_id", userId) - .throwOnError(); -} - -export const handler: Handlers = { - /** - * This handler handles Supabase webhook when an insert event is triggered on the `users_customers` table. - * A HTTP parameter of key `API_ROUTE_SECRET` and value of that in the `.env` file must be set. - */ - async POST(request) { - if (!hasRouteSecret(request)) { - await request.body?.cancel(); - return new Response(null, { status: 401 }); - } - - const { record: { user_id } } = await request.json(); - const { data } = await supabaseAdminClient.auth.admin.getUserById(user_id); - - const customer = await stripe.customers.create({ - email: data.user!.email, - }); - - await setStripeCustomerId(supabaseAdminClient, { - userId: user_id, - stripeCustomerId: customer.id, - }); - - return Response.json(null, { status: 201 }); - }, -}; diff --git a/routes/dashboard/_middleware.ts b/routes/dashboard/_middleware.ts index 26b165bcf..2e1b39ccc 100644 --- a/routes/dashboard/_middleware.ts +++ b/routes/dashboard/_middleware.ts @@ -1,14 +1,14 @@ import { MiddlewareHandlerContext } from "$fresh/server.ts"; -import { createSupabaseClient } from "@/utils/supabase.ts"; +import { createOrGetCustomer, createSupabaseClient } from "@/utils/supabase.ts"; import { assert } from "std/testing/asserts.ts"; -import type { Session } from "@supabase/supabase-js"; +import type { Session, SupabaseClient } from "@supabase/supabase-js"; +import { Database } from "@/utils/supabase_types.ts"; +import { stripe } from "@/utils/stripe.ts"; export interface DashboardState { session: Session; - subscription: { - stripeCustomerId: string; - isSubscribed: boolean; - }; + supabaseClient: SupabaseClient; + customer: Database["public"]["Tables"]["customers"]["Insert"]; } export function getLoginPath(redirectUrl: string) { @@ -26,18 +26,10 @@ export async function handler( const { data: { session } } = await supabaseClient.auth.getSession(); assert(session); - ctx.state.session = session; - - const { data } = await supabaseClient - .from("customers") - .select("stripe_customer_id, is_subscribed") - .single() - .throwOnError(); - ctx.state.subscription = { - stripeCustomerId: data!.stripe_customer_id, - isSubscribed: data!.is_subscribed, - }; + ctx.state.session = session; + ctx.state.supabaseClient = supabaseClient; + ctx.state.customer = await createOrGetCustomer(supabaseClient, stripe); const response = await ctx.next(); /** diff --git a/routes/dashboard/account.tsx b/routes/dashboard/account.tsx index a10219805..3dffc25c1 100644 --- a/routes/dashboard/account.tsx +++ b/routes/dashboard/account.tsx @@ -10,7 +10,7 @@ export const handler: Handlers = { }; export default function AccountPage(props: PageProps) { - const action = props.data.subscription.isSubscribed ? "Manage" : "Upgrade"; + const action = props.data.customer.is_subscribed ? "Manage" : "Upgrade"; return ( <> diff --git a/routes/dashboard/api/todo.ts b/routes/dashboard/api/todo.ts index d9bab10aa..11d9983ec 100644 --- a/routes/dashboard/api/todo.ts +++ b/routes/dashboard/api/todo.ts @@ -1,14 +1,13 @@ import type { Handlers } from "$fresh/server.ts"; import { AuthError } from "@supabase/supabase-js"; -import { createSupabaseClient } from "@/utils/supabase.ts"; import { createTodo, deleteTodo } from "@/utils/todos.ts"; +import type { DashboardState } from "@/routes/dashboard/_middleware.ts"; -export const handler: Handlers = { - async POST(request) { +export const handler: Handlers = { + async POST(request, ctx) { try { - const supabaseClient = createSupabaseClient(request.headers); const todo = await request.json(); - await createTodo(supabaseClient, todo); + await createTodo(ctx.state.supabaseClient, todo); return Response.json(null, { status: 201 }); } catch (error) { @@ -18,11 +17,10 @@ export const handler: Handlers = { return new Response(error.message, { status }); } }, - async DELETE(request) { + async DELETE(request, ctx) { try { - const supabaseClient = createSupabaseClient(request.headers); const { id } = await request.json(); - await deleteTodo(supabaseClient, id); + await deleteTodo(ctx.state.supabaseClient, id); return new Response(null, { status: 202 }); } catch (error) { diff --git a/routes/dashboard/manage-subscription.ts b/routes/dashboard/manage-subscription.ts index c2e37616d..e4e04876c 100644 --- a/routes/dashboard/manage-subscription.ts +++ b/routes/dashboard/manage-subscription.ts @@ -6,7 +6,7 @@ import { DashboardState } from "./_middleware.ts"; export const handler: Handlers = { async GET(request, ctx) { const { url } = await stripe.billingPortal.sessions.create({ - customer: ctx.state.subscription.stripeCustomerId, + customer: ctx.state.customer.stripe_customer_id, return_url: new URL(request.url).origin + "/dashboard", }); diff --git a/routes/dashboard/todos.tsx b/routes/dashboard/todos.tsx index a36eef35b..3f12b9962 100644 --- a/routes/dashboard/todos.tsx +++ b/routes/dashboard/todos.tsx @@ -5,16 +5,14 @@ import TodoList from "@/islands/TodoList.tsx"; import Notice from "@/components/Notice.tsx"; import { DashboardState } from "./_middleware.ts"; import Dashboard from "@/components/Dashboard.tsx"; -import { createSupabaseClient } from "../../utils/supabase.ts"; -interface Data extends DashboardState { +interface TodosPageProps extends DashboardState { todos: Todo[]; } -export const handler: Handlers = { - async GET(request, ctx) { - const todos = await getTodos(createSupabaseClient(request.headers)); - +export const handler: Handlers = { + async GET(_request, ctx) { + const todos = await getTodos(ctx.state.supabaseClient); return ctx.render({ ...ctx.state, todos, @@ -22,12 +20,12 @@ export const handler: Handlers = { }, }; -export default function TodosPage(props: PageProps) { +export default function TodosPage(props: PageProps) { return ( <> - {!props.data.subscription.isSubscribed && ( + {!props.data.customer.is_subscribed && ( ) { /> )} diff --git a/routes/dashboard/upgrade-subscription.ts b/routes/dashboard/upgrade-subscription.ts index a34120244..6a0e75358 100644 --- a/routes/dashboard/upgrade-subscription.ts +++ b/routes/dashboard/upgrade-subscription.ts @@ -3,12 +3,11 @@ import { stripe } from "@/utils/stripe.ts"; import { DashboardState } from "./_middleware.ts"; import { STRIPE_PREMIUM_PLAN_PRICE_ID } from "@/constants.ts"; -// deno-lint-ignore no-explicit-any -export const handler: Handlers = { +export const handler: Handlers = { async GET(request, ctx) { const { url } = await stripe.checkout.sessions.create({ success_url: new URL(request.url).origin + "/dashboard/todos", - customer: ctx.state.subscription.stripeCustomerId, + customer: ctx.state.customer.stripe_customer_id, line_items: [ { price: STRIPE_PREMIUM_PLAN_PRICE_ID, diff --git a/scripts/generate_key.ts b/scripts/generate_key.ts deleted file mode 100644 index 134e73a77..000000000 --- a/scripts/generate_key.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** This script can be used to generate the value for the 'API_ROUTE_SECRET' environment variable. */ -import { toHashString } from "std/crypto/to_hash_string.ts"; - -const BYTE_LENGTH = 32; - -const bytes = new Uint32Array(BYTE_LENGTH); -crypto.getRandomValues(bytes); -const key = toHashString(bytes); - -console.log(key); diff --git a/utils/supabase.ts b/utils/supabase.ts index f3be6152f..4cd89e14f 100644 --- a/utils/supabase.ts +++ b/utils/supabase.ts @@ -1,9 +1,8 @@ import type { Database } from "./supabase_types.ts"; import { createServerSupabaseClient } from "@supabase/auth-helpers-shared"; import { getCookies, setCookie } from "std/http/cookie.ts"; -import { createClient } from "@supabase/supabase-js"; - -export type SupabaseClient = ReturnType; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import type { Stripe } from "stripe"; export function createSupabaseClient( requestHeaders: Headers, @@ -36,3 +35,41 @@ export const supabaseAdminClient = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABSE_SERVICE_KEY")!, ); + +async function getCustomer( + supabaseClient: SupabaseClient, +) { + const { data } = await supabaseClient + .from("customers") + .select() + .single(); + return data; +} + +async function createCustomer( + supabaseClient: SupabaseClient, + customer: Database["public"]["Tables"]["customers"]["Insert"], +) { + const { data } = await supabaseClient + .from("customers") + .insert(customer) + .select() + .single() + .throwOnError(); + return data!; +} + +export async function createOrGetCustomer( + supabaseClient: SupabaseClient, + stripeClient: Stripe, +) { + const customer = await getCustomer(supabaseClient); + if (customer) return customer; + + const { data: { user } } = await supabaseClient.auth.getUser(); + const { id } = await stripeClient.customers.create({ email: user!.email }); + return await createCustomer(supabaseClient, { + stripe_customer_id: id, + is_subscribed: false, + }); +} diff --git a/utils/supabase_types.ts b/utils/supabase_types.ts index 7e04a32f5..8a6fec784 100644 --- a/utils/supabase_types.ts +++ b/utils/supabase_types.ts @@ -9,20 +9,37 @@ export type Json = export interface Database { public: { Tables: { + customers: { + Row: { + is_subscribed: boolean; + stripe_customer_id: string; + user_id: string; + }; + Insert: { + is_subscribed?: boolean; + stripe_customer_id: string; + user_id?: string; + }; + Update: { + is_subscribed?: boolean; + stripe_customer_id?: string; + user_id?: string; + }; + }; todos: { Row: { - id: string; - name: string; + id: number; + name: string | null; user_id: string; }; Insert: { - id?: string; - name: string; + id?: number; + name?: string | null; user_id?: string; }; Update: { - id?: string; - name?: string; + id?: number; + name?: string | null; user_id?: string; }; }; @@ -31,10 +48,7 @@ export interface Database { [_ in never]: never; }; Functions: { - install_available_extensions_and_test: { - Args: Record; - Returns: boolean; - }; + [_ in never]: never; }; Enums: { [_ in never]: never; From 8f506e6831444f53740da99d6e9c8cd2bc3eef60 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Fri, 31 Mar 2023 08:19:06 +1100 Subject: [PATCH 9/9] minor fixes and cleanups --- routes/dashboard/todos.tsx | 7 ++++--- utils/todos.ts | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/routes/dashboard/todos.tsx b/routes/dashboard/todos.tsx index 3f12b9962..107be3894 100644 --- a/routes/dashboard/todos.tsx +++ b/routes/dashboard/todos.tsx @@ -1,13 +1,14 @@ import type { Handlers, PageProps } from "$fresh/server.ts"; -import { getTodos, type Todo } from "@/utils/todos.ts"; +import { getTodos } from "@/utils/todos.ts"; import Head from "@/components/Head.tsx"; import TodoList from "@/islands/TodoList.tsx"; import Notice from "@/components/Notice.tsx"; import { DashboardState } from "./_middleware.ts"; import Dashboard from "@/components/Dashboard.tsx"; +import { Database } from "@/utils/supabase_types.ts"; interface TodosPageProps extends DashboardState { - todos: Todo[]; + todos: Database["public"]["Tables"]["todos"]["Insert"][]; } export const handler: Handlers = { @@ -40,7 +41,7 @@ export default function TodosPage(props: PageProps) { /> )} diff --git a/utils/todos.ts b/utils/todos.ts index 10e964d6a..d08315db9 100644 --- a/utils/todos.ts +++ b/utils/todos.ts @@ -1,13 +1,9 @@ -import type { SupabaseClient } from "./supabase.ts"; - -export interface Todo { - id: string; - name: string; -} +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "./supabase_types.ts"; const TABLE_NAME = "todos"; -export async function getTodos(client: SupabaseClient) { +export async function getTodos(client: SupabaseClient) { const { data } = await client .from(TABLE_NAME) .select("id, name") @@ -15,14 +11,20 @@ export async function getTodos(client: SupabaseClient) { return data!; } -export async function createTodo(client: SupabaseClient, todo: Todo) { +export async function createTodo( + client: SupabaseClient, + todo: Database["public"]["Tables"]["todos"]["Insert"], +) { await client .from(TABLE_NAME) .insert(todo) .throwOnError(); } -export async function deleteTodo(client: SupabaseClient, id: Todo["id"]) { +export async function deleteTodo( + client: SupabaseClient, + id: Database["public"]["Tables"]["todos"]["Insert"]["id"], +) { await client .from(TABLE_NAME) .delete()