Merge pull request #36 from jcreek/29-refactor-stripe-and-supabase-implementation

29 refactor stripe and supabase implementation
This commit is contained in:
Josh Creek
2024-09-24 21:00:14 +01:00
committed by GitHub
38 changed files with 7553 additions and 11274 deletions
+4 -1
View File
@@ -1,3 +1,6 @@
PUBLIC_SUPABASE_URL="" PUBLIC_SUPABASE_URL=""
PUBLIC_SUPABASE_ANON_KEY="" PUBLIC_SUPABASE_ANON_KEY=""
PUBLIC_STRIPE_SECRET_KEY="" SUPABASE_SERVICE_ROLE_KEY=""
STRIPE_SECRET_KEY=""
STRIPE_ENDPOINT_SECRET=""
VITE_PRODUCT_ID_EXAMPLEPRODUCT=""
+7
View File
@@ -17,6 +17,13 @@ npm run dev -- --open
An example `.env` file is provided in the repository. You will need to copy `.env.example` to `.env` and fill in the values with your own credentials. An example `.env` file is provided in the repository. You will need to copy `.env.example` to `.env` and fill in the values with your own credentials.
If you want a fully local development environment (other than stripe) then you can follow the instructions in the [database setup](/docs/database-setup.md) document and make use of the local supabase instance and a local email service.
- [Local DB Url](http://localhost:54323/)
- [Local Email Monitoring Url](http://localhost:54324/)
For stripe, you can forward the events to your local server using `stripe listen --forward-to localhost:5173/api/webhook/stripe --skip-verify` in a separate terminal window.
## Building ## Building
To create a production version: To create a production version:
+37 -165
View File
@@ -4,169 +4,14 @@ Databases are stored in Supabase. The auth table for signing up and logging in u
## Initial Setup ## Initial Setup
To add the tables for recording their active subscriptions and bought products, as well as products and prices, you can use the following SQL script in the Supabase SQL Editor. To add the tables for recording their active subscriptions and bought products, as well as products and prices, you can use the SQL scripts from the `supabase/migrations` folder in the Supabase SQL Editor.
```sql ## Common commands for local development
/**
* USERS
* Note: This table contains user data. Users should only be able to view and update their own data.
*/
create table users (
-- UUID from auth.users
id uuid references auth.users not null primary key,
full_name text,
avatar_url text,
-- The customer's billing address, stored in JSON format.
billing_address jsonb,
-- Stores your customer's payment instruments.
payment_method jsonb
);
alter table users
enable row level security;
create policy "Can view own user data." on users
for select using ((select auth.uid()) = id);
create policy "Can update own user data." on users
for update using ((select auth.uid()) = id);
/** - `npx supabase start` - Start the local Supabase instance
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth. - `npx supabase stop` - Stop the local Supabase instance
*/ - `npx supabase db reset` - Reset the local database
create function public.handle_new_user() - `npx supabase gen types typescript --local > src/lib/types/supabase.d.ts` - Generate types for the local database
returns trigger as
$$
begin
insert into public.users (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$
language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row
execute procedure public.handle_new_user();
/**
* CUSTOMERS
* Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.
*/
create table customers (
-- UUID from auth.users
id uuid references auth.users not null primary key,
-- The user's customer ID in Stripe. User must not be able to update this.
stripe_customer_id text
);
alter table customers enable row level security;
-- No policies as this is a private table that the user must not have access to.
/**
* PRODUCTS
* Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create table products (
-- Product ID from Stripe, e.g. prod_1234.
id text primary key,
-- Whether the product is currently available for purchase.
active boolean,
-- The product's name, meant to be displayable to the customer. Whenever this product is sold via a subscription, name will show up on associated invoice line item descriptions.
name text,
-- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.
description text,
-- A URL of the product image in Stripe, meant to be displayable to the customer.
image text,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb
);
alter table products
enable row level security;
create policy "Allow public read-only access." on products
for select using (true);
/**
* PRICES
* Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create type pricing_type as enum ('one_time', 'recurring');
create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
create table prices (
-- Price ID from Stripe, e.g. price_1234.
id text primary key,
-- The ID of the prduct that this price belongs to.
product_id text references products,
-- Whether the price can be used for new purchases.
active boolean,
-- A brief description of the price.
description text,
-- The unit amount as a positive integer in the smallest currency unit (e.g., 100 cents for US$1.00 or 100 for ¥100, a zero-decimal currency).
unit_amount bigint,
-- Three-letter ISO currency code, in lowercase.
currency text check (char_length(currency) = 3),
-- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.
type pricing_type,
-- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.
interval pricing_plan_interval,
-- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
interval_count integer,
-- Default number of trial days when subscribing a customer to this price using [`trial_from_plan=true`](https://stripe.com/docs/api#create_subscription-trial_from_plan).
trial_period_days integer,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb
);
alter table prices
enable row level security;
create policy "Allow public read-only access." on prices
for select using (true);
/**
* SUBSCRIPTIONS
* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid');
create table subscriptions (
-- Subscription ID from Stripe, e.g. sub_1234.
id text primary key,
user_id uuid references auth.users not null,
-- The status of the subscription object, one of subscription_status type above.
status subscription_status,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb,
-- ID of the price that created this subscription.
price_id text references prices,
-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
quantity integer,
-- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
cancel_at_period_end boolean,
-- Time at which the subscription was created.
created timestamp with time zone default timezone('utc'::text, now()) not null,
-- Start of the current period that the subscription has been invoiced for.
current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
-- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
-- If the subscription has ended, the timestamp of the date the subscription ended.
ended_at timestamp with time zone default timezone('utc'::text, now()),
-- A date in the future at which the subscription will automatically get canceled.
cancel_at timestamp with time zone default timezone('utc'::text, now()),
-- If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with `cancel_at_period_end`, `canceled_at` will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state.
canceled_at timestamp with time zone default timezone('utc'::text, now()),
-- If the subscription has a trial, the beginning of that trial.
trial_start timestamp with time zone default timezone('utc'::text, now()),
-- If the subscription has a trial, the end of that trial.
trial_end timestamp with time zone default timezone('utc'::text, now())
);
alter table subscriptions
enable row level security;
create policy "Can only view own subs data." on subscriptions
for select using ((select auth.uid()) = user_id);
/**
* REALTIME SUBSCRIPTIONS
* Only allow realtime listening on public tables.
*/
drop publication if exists supabase_realtime;
create publication supabase_realtime
for table products, prices;
```
## Generating Types ## Generating Types
@@ -178,11 +23,11 @@ To get the types for the tables, you can follow these instructions taken from [h
`npx supabase init` `npx supabase init`
Replace `$PROJECT_REF` with your project reference: Replace `$PROJECT_REF` with your project reference and replace `types/supabase.d.ts` with whatever file you want your types in:
`npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts` `npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > src/lib/types/supabase.d.ts`
Replace `types/supabase.d.ts` with whatever file you want your types in: If you are using a local Supabase instance, you can use the following command to generate the types:
`npx supabase gen types typescript --local > types/supabase.d.ts` `npx supabase gen types typescript --local > src/lib/types/supabase.d.ts`
## Local DB Development ## Local DB Development
@@ -196,6 +41,8 @@ npx supabase start
npx supabase stop npx supabase stop
``` ```
This will start the local DB and email monitoring services. You can access the web interfaces for the local DB at [http://localhost:54323/](http://localhost:54323/) and the email monitoring at [http://localhost:54324/](http://localhost:54324/).It will also give you the API URL, anon key and service_role key to put in your `.env` file for local development.
## Migrations ## Migrations
There are two options to create a migration file: There are two options to create a migration file:
@@ -237,3 +84,28 @@ Reset database in cloud
> Prerequisite login using `npx supabase login` > Prerequisite login using `npx supabase login`
`npx supabase db reset --linked` `npx supabase db reset --linked`
## Enabling sending emails with Brevo
1. Go to the Project Settings in Supabase, then Authentication, then SMTP Settings. You should end up at `https://supabase.com/dashboard/project/[your-project-here]/settings/auth`
2. Enable custom SMTP and fill in the required fields with your Brevo credentials.
- You need to have an email address that you can send stuff from
- You need to have a Brevo account
- sender email as noreply@yourdomain.com
3. `https://app.brevo.com/senders/list` to add senders
4. Add a sender for noreply@yourdomain.com
5. Verify your email domain via DKIM or DMARC
6. In Brevo go to `https://app.brevo.com/settings/keys/smtp` to see your SMTP credentials.
7. Fill in the SMTP credentials in Supabase.
8. Go to `https://supabase.com/dashboard/project/[your-project-here]/auth/url-configuration`
9. Add these redirect URLs:
- `http://localhost:3000`
- `http://localhost:3000/**`
- `https://www.your-domain.com`
- `https://your-domain.com`
- `http://localhost:5173`
- `http://localhost:5173/**`
- Add your netlify.app domain here with **-- at the start, e.g. `http://**--your-netlify-app.netlify.app`
+7 -3
View File
@@ -16,9 +16,9 @@ stripe listen
Add the secret key to to your .env(.local): Add the secret key to to your .env(.local):
`PUBLIC_STRIPE_ENDPOINT_SECRET` `STRIPE_ENDPOINT_SECRET`
### Testing the webhook locally ## Testing the webhook locally
You can use the [Stripe CLI to forward events to your local server](https://docs.stripe.com/webhooks): You can use the [Stripe CLI to forward events to your local server](https://docs.stripe.com/webhooks):
@@ -32,4 +32,8 @@ To disable HTTPS certificate verification, use the --skip-verify optional flag.
stripe listen --forward-to localhost:5173/api/webhook/stripe --skip-verify stripe listen --forward-to localhost:5173/api/webhook/stripe --skip-verify
``` ```
TODO - Add instructions for what else to do to get the webhook working locally. ## Purchaseable products
As well as subscriptions you can also create purchaseable products in Stripe. These can be used for one-off payments or for products that are not subscription based. To add these to the application you can follow the example product's pattern.
There is a config value `VITE_PRODUCT_ID_EXAMPLEPRODUCT` that is used to identify the product in the Stripe checkout session. This is the product code from Stripe. The actual 'product' that users are getting access to is at the `src/routes/tools/exampleproduct` path.
-10473
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -9,7 +9,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write ." "format": "prettier --write .",
"generate": "supabase gen types typescript --local > src/lib/types/supabase.d.ts && prettier --write src/lib/types/supabase.d.ts"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
@@ -41,6 +42,7 @@
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.4.6",
"vite-plugin-tailwind-purgecss": "^0.3.3" "vite-plugin-tailwind-purgecss": "^0.3.3"
}, },
"type": "module", "type": "module",
@@ -51,5 +53,6 @@
}, },
"engines": { "engines": {
"node": ">=18.13.0" "node": ">=18.13.0"
} },
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1"
} }
+6897
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')],
};
+5 -1
View File
@@ -2,10 +2,14 @@
// for information about these interfaces // for information about these interfaces
// and what to do when importing types // and what to do when importing types
import { SupabaseClient, Session, User } from '@supabase/supabase-js'; import { SupabaseClient, Session, User } from '@supabase/supabase-js';
import type { Database } from '$types/supabase'; import type { Database } from '$lib/types/supabase';
declare global { declare global {
const __DATE__: string; const __DATE__: string;
const __RELOAD_SW__: boolean; const __RELOAD_SW__: boolean;
// Enable images with query params
declare module '*&img';
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
-1
View File
@@ -1 +0,0 @@
/* Write your global styles here, in PostCSS syntax */
+18 -13
View File
@@ -1,23 +1,28 @@
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr'; import { createServerClient } from '@supabase/ssr';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import type { Database } from '$lib/types/supabase';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { event.locals.supabase = createServerClient<Database>(
cookies: { PUBLIC_SUPABASE_URL,
getAll: () => event.cookies.getAll(), PUBLIC_SUPABASE_ANON_KEY,
/** {
* SvelteKit's cookies API requires `path` to be explicitly set in cookies: {
* the cookie options. Setting `path` to `/` replicates previous/ getAll: () => event.cookies.getAll(),
* standard behavior. /**
*/ * SvelteKit's cookies API requires `path` to be explicitly set in
setAll: (cookiesToSet) => { * the cookie options. Setting `path` to `/` replicates previous/
cookiesToSet.forEach(({ name, value, options }) => { * standard behavior.
event.cookies.set(name, value, { ...options, path: '/' }); */
}); setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
}
} }
} }
}); );
/** /**
* Unlike `supabase.auth.getSession()`, which returns the session _without_ * Unlike `supabase.auth.getSession()`, which returns the session _without_
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
export let supabase: any;
let error = '',
message = '',
loading = false,
email = '';
async function submit() {
error = '';
message = '';
loading = true;
const { error: err } = await supabase.auth.signInWithOtp({ email });
if (err) error = err.message;
else message = 'Check your email for the magic link.';
loading = false;
}
</script>
<div class="p-4">
<div class="component">
<div class="container">
<form on:submit|preventDefault={submit}>
<label class="input input-bordered flex items-center gap-2">
Email
<input
type="text"
name="email"
class="grow"
placeholder="Your email address"
bind:value={email}
/>
</label>
<button class="btn btn-active btn-primary w-full" on:click disabled={loading}
>Send magic link</button
>
{#if message}
<div class="text-success">
{message}
</div>
{/if}
{#if error}
<div class="text-error">
{error}
</div>
{/if}
</form>
</div>
</div>
</div>
<style>
.component {
width: 100%;
}
.container {
display: flex;
flex-direction: column;
}
</style>
+2 -2
View File
@@ -8,7 +8,7 @@
<div tabindex="0" role="button" class="">Parent</div> <div tabindex="0" role="button" class="">Parent</div>
{:else} {:else}
<details> <details>
<summary>Parent</summary> <summary class="flex items-center"><span class="mr-1">&#x25BC;</span>Parent </summary>
</details> </details>
{/if} {/if}
<ul <ul
@@ -20,4 +20,4 @@
<li><a href="/">Submenu 2</a></li> <li><a href="/">Submenu 2</a></li>
</ul> </ul>
</li> </li>
<li><a href="/">Item 3</a></li> <li><a href="/tools/exampleproduct">Example Product</a></li>
-19
View File
@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import stripe from 'stripe';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
let email = ''; let email = '';
let password = ''; let password = '';
@@ -8,8 +6,6 @@
// Access the supabase client from the layout data // Access the supabase client from the layout data
export let supabase: any; export let supabase: any;
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
async function signUpNewUser() { async function signUpNewUser() {
try { try {
const { data, error } = await supabase.auth.signUp({ const { data, error } = await supabase.auth.signUp({
@@ -25,21 +21,6 @@
throw error; throw error;
} }
const customer = await stripeClient.customers.create({
email
});
const response = await supabase
.from('user')
.update({
customer_id: customer.id
})
.match({
id: data.user.id
});
console.log(response);
// Handle success (optional) // Handle success (optional)
} catch (error: any) { } catch (error: any) {
console.error('Sign up error:', error.message); console.error('Sign up error:', error.message);
+75 -327
View File
@@ -66,7 +66,7 @@ export type Database = {
interval: Database["public"]["Enums"]["pricing_plan_interval"] | null interval: Database["public"]["Enums"]["pricing_plan_interval"] | null
interval_count: number | null interval_count: number | null
metadata: Json | null metadata: Json | null
product_id: string | null product_id: string
trial_period_days: number | null trial_period_days: number | null
type: Database["public"]["Enums"]["pricing_type"] | null type: Database["public"]["Enums"]["pricing_type"] | null
unit_amount: number | null unit_amount: number | null
@@ -79,7 +79,7 @@ export type Database = {
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
interval_count?: number | null interval_count?: number | null
metadata?: Json | null metadata?: Json | null
product_id?: string | null product_id: string
trial_period_days?: number | null trial_period_days?: number | null
type?: Database["public"]["Enums"]["pricing_type"] | null type?: Database["public"]["Enums"]["pricing_type"] | null
unit_amount?: number | null unit_amount?: number | null
@@ -92,7 +92,7 @@ export type Database = {
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
interval_count?: number | null interval_count?: number | null
metadata?: Json | null metadata?: Json | null
product_id?: string | null product_id?: string
trial_period_days?: number | null trial_period_days?: number | null
type?: Database["public"]["Enums"]["pricing_type"] | null type?: Database["public"]["Enums"]["pricing_type"] | null
unit_amount?: number | null unit_amount?: number | null
@@ -110,30 +110,85 @@ export type Database = {
products: { products: {
Row: { Row: {
active: boolean | null active: boolean | null
created_at: string
description: string | null description: string | null
features: string[] | null
id: string id: string
image: string | null images: string[] | null
metadata: Json | null metadata: Json | null
name: string | null name: string | null
updated_at: string
} }
Insert: { Insert: {
active?: boolean | null active?: boolean | null
created_at?: string
description?: string | null description?: string | null
features?: string[] | null
id: string id: string
image?: string | null images?: string[] | null
metadata?: Json | null metadata?: Json | null
name?: string | null name?: string | null
updated_at?: string
} }
Update: { Update: {
active?: boolean | null active?: boolean | null
created_at?: string
description?: string | null description?: string | null
features?: string[] | null
id?: string id?: string
image?: string | null images?: string[] | null
metadata?: Json | null metadata?: Json | null
name?: string | null name?: string | null
updated_at?: string
} }
Relationships: [] Relationships: []
} }
purchases: {
Row: {
id: string
price_id: string
product_id: string
purchased_at: string
user_id: string
}
Insert: {
id?: string
price_id: string
product_id: string
purchased_at?: string
user_id: string
}
Update: {
id?: string
price_id?: string
product_id?: string
purchased_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "purchases_price_id_fkey"
columns: ["price_id"]
isOneToOne: false
referencedRelation: "prices"
referencedColumns: ["id"]
},
{
foreignKeyName: "purchases_product_id_fkey"
columns: ["product_id"]
isOneToOne: false
referencedRelation: "products"
referencedColumns: ["id"]
},
{
foreignKeyName: "purchases_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
subscriptions: { subscriptions: {
Row: { Row: {
cancel_at: string | null cancel_at: string | null
@@ -146,6 +201,7 @@ export type Database = {
id: string id: string
metadata: Json | null metadata: Json | null
price_id: string | null price_id: string | null
product_id: string
quantity: number | null quantity: number | null
status: Database["public"]["Enums"]["subscription_status"] | null status: Database["public"]["Enums"]["subscription_status"] | null
trial_end: string | null trial_end: string | null
@@ -163,6 +219,7 @@ export type Database = {
id: string id: string
metadata?: Json | null metadata?: Json | null
price_id?: string | null price_id?: string | null
product_id: string
quantity?: number | null quantity?: number | null
status?: Database["public"]["Enums"]["subscription_status"] | null status?: Database["public"]["Enums"]["subscription_status"] | null
trial_end?: string | null trial_end?: string | null
@@ -180,6 +237,7 @@ export type Database = {
id?: string id?: string
metadata?: Json | null metadata?: Json | null
price_id?: string | null price_id?: string | null
product_id?: string
quantity?: number | null quantity?: number | null
status?: Database["public"]["Enums"]["subscription_status"] | null status?: Database["public"]["Enums"]["subscription_status"] | null
trial_end?: string | null trial_end?: string | null
@@ -194,6 +252,13 @@ export type Database = {
referencedRelation: "prices" referencedRelation: "prices"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "subscriptions_product_id_fkey"
columns: ["product_id"]
isOneToOne: false
referencedRelation: "products"
referencedColumns: ["id"]
},
{ {
foreignKeyName: "subscriptions_user_id_fkey" foreignKeyName: "subscriptions_user_id_fkey"
columns: ["user_id"] columns: ["user_id"]
@@ -205,25 +270,16 @@ export type Database = {
} }
users: { users: {
Row: { Row: {
avatar_url: string | null
billing_address: Json | null
full_name: string | null
id: string id: string
payment_method: Json | null name: string | null
} }
Insert: { Insert: {
avatar_url?: string | null
billing_address?: Json | null
full_name?: string | null
id: string id: string
payment_method?: Json | null name?: string | null
} }
Update: { Update: {
avatar_url?: string | null
billing_address?: Json | null
full_name?: string | null
id?: string id?: string
payment_method?: Json | null name?: string | null
} }
Relationships: [ Relationships: [
{ {
@@ -253,315 +309,7 @@ export type Database = {
| "incomplete_expired" | "incomplete_expired"
| "past_due" | "past_due"
| "unpaid" | "unpaid"
} | "paused"
CompositeTypes: {
[_ in never]: never
}
}
storage: {
Tables: {
buckets: {
Row: {
allowed_mime_types: string[] | null
avif_autodetection: boolean | null
created_at: string | null
file_size_limit: number | null
id: string
name: string
owner: string | null
owner_id: string | null
public: boolean | null
updated_at: string | null
}
Insert: {
allowed_mime_types?: string[] | null
avif_autodetection?: boolean | null
created_at?: string | null
file_size_limit?: number | null
id: string
name: string
owner?: string | null
owner_id?: string | null
public?: boolean | null
updated_at?: string | null
}
Update: {
allowed_mime_types?: string[] | null
avif_autodetection?: boolean | null
created_at?: string | null
file_size_limit?: number | null
id?: string
name?: string
owner?: string | null
owner_id?: string | null
public?: boolean | null
updated_at?: string | null
}
Relationships: []
}
migrations: {
Row: {
executed_at: string | null
hash: string
id: number
name: string
}
Insert: {
executed_at?: string | null
hash: string
id: number
name: string
}
Update: {
executed_at?: string | null
hash?: string
id?: number
name?: string
}
Relationships: []
}
objects: {
Row: {
bucket_id: string | null
created_at: string | null
id: string
last_accessed_at: string | null
metadata: Json | null
name: string | null
owner: string | null
owner_id: string | null
path_tokens: string[] | null
updated_at: string | null
version: string | null
}
Insert: {
bucket_id?: string | null
created_at?: string | null
id?: string
last_accessed_at?: string | null
metadata?: Json | null
name?: string | null
owner?: string | null
owner_id?: string | null
path_tokens?: string[] | null
updated_at?: string | null
version?: string | null
}
Update: {
bucket_id?: string | null
created_at?: string | null
id?: string
last_accessed_at?: string | null
metadata?: Json | null
name?: string | null
owner?: string | null
owner_id?: string | null
path_tokens?: string[] | null
updated_at?: string | null
version?: string | null
}
Relationships: [
{
foreignKeyName: "objects_bucketId_fkey"
columns: ["bucket_id"]
isOneToOne: false
referencedRelation: "buckets"
referencedColumns: ["id"]
},
]
}
s3_multipart_uploads: {
Row: {
bucket_id: string
created_at: string
id: string
in_progress_size: number
key: string
owner_id: string | null
upload_signature: string
version: string
}
Insert: {
bucket_id: string
created_at?: string
id: string
in_progress_size?: number
key: string
owner_id?: string | null
upload_signature: string
version: string
}
Update: {
bucket_id?: string
created_at?: string
id?: string
in_progress_size?: number
key?: string
owner_id?: string | null
upload_signature?: string
version?: string
}
Relationships: [
{
foreignKeyName: "s3_multipart_uploads_bucket_id_fkey"
columns: ["bucket_id"]
isOneToOne: false
referencedRelation: "buckets"
referencedColumns: ["id"]
},
]
}
s3_multipart_uploads_parts: {
Row: {
bucket_id: string
created_at: string
etag: string
id: string
key: string
owner_id: string | null
part_number: number
size: number
upload_id: string
version: string
}
Insert: {
bucket_id: string
created_at?: string
etag: string
id?: string
key: string
owner_id?: string | null
part_number: number
size?: number
upload_id: string
version: string
}
Update: {
bucket_id?: string
created_at?: string
etag?: string
id?: string
key?: string
owner_id?: string | null
part_number?: number
size?: number
upload_id?: string
version?: string
}
Relationships: [
{
foreignKeyName: "s3_multipart_uploads_parts_bucket_id_fkey"
columns: ["bucket_id"]
isOneToOne: false
referencedRelation: "buckets"
referencedColumns: ["id"]
},
{
foreignKeyName: "s3_multipart_uploads_parts_upload_id_fkey"
columns: ["upload_id"]
isOneToOne: false
referencedRelation: "s3_multipart_uploads"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
can_insert_object: {
Args: {
bucketid: string
name: string
owner: string
metadata: Json
}
Returns: undefined
}
extension: {
Args: {
name: string
}
Returns: string
}
filename: {
Args: {
name: string
}
Returns: string
}
foldername: {
Args: {
name: string
}
Returns: string[]
}
get_size_by_bucket: {
Args: Record<PropertyKey, never>
Returns: {
size: number
bucket_id: string
}[]
}
list_multipart_uploads_with_delimiter: {
Args: {
bucket_id: string
prefix_param: string
delimiter_param: string
max_keys?: number
next_key_token?: string
next_upload_token?: string
}
Returns: {
key: string
id: string
created_at: string
}[]
}
list_objects_with_delimiter: {
Args: {
bucket_id: string
prefix_param: string
delimiter_param: string
max_keys?: number
start_after?: string
next_token?: string
}
Returns: {
name: string
id: string
metadata: Json
updated_at: string
}[]
}
operation: {
Args: Record<PropertyKey, never>
Returns: string
}
search: {
Args: {
prefix: string
bucketname: string
limits?: number
levels?: number
offsets?: number
search?: string
sortcolumn?: string
sortorder?: string
}
Returns: {
name: string
id: string
updated_at: string
created_at: string
last_accessed_at: string
metadata: Json
}[]
}
}
Enums: {
[_ in never]: never
} }
CompositeTypes: { CompositeTypes: {
[_ in never]: never [_ in never]: never
+4
View File
@@ -0,0 +1,4 @@
import Stripe from 'stripe'
import { STRIPE_SECRET_KEY } from '$env/static/private'
export const stripe = new Stripe(STRIPE_SECRET_KEY)
+76 -17
View File
@@ -1,8 +1,9 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import stripe from 'stripe'; import stripe from 'stripe';
import type { Database, Tables, TablesInsert } from '$types/supabase'; import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
import { PUBLIC_SUPABASE_URL, PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public'; import { PUBLIC_SUPABASE_URL } from '$env/static/public';
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private'; import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
import { stripe as stripeClient } from '$lib/utils/stripe';
const toDateTime = (secs: number) => { const toDateTime = (secs: number) => {
const t = new Date(+0); // Unix epoch start. const t = new Date(+0); // Unix epoch start.
@@ -10,8 +11,6 @@ const toDateTime = (secs: number) => {
return t; return t;
}; };
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
type Product = Tables<'products'>; type Product = Tables<'products'>;
type Price = Tables<'prices'>; type Price = Tables<'prices'>;
@@ -31,8 +30,11 @@ const upsertProductRecord = async (product: stripe.Product) => {
active: product.active, active: product.active,
name: product.name, name: product.name,
description: product.description ?? null, description: product.description ?? null,
image: product.images?.[0] ?? null, features: product.marketing_features.map(({ name }) => name || '').filter((name) => !!name),
metadata: product.metadata images: product.images ?? null,
metadata: product.metadata,
created_at: new Date(product.created * 1000).toISOString(),
updated_at: new Date(product.updated * 1000).toISOString()
}; };
const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]); const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]);
@@ -46,6 +48,8 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
product_id: typeof price.product === 'string' ? price.product : '', product_id: typeof price.product === 'string' ? price.product : '',
active: price.active, active: price.active,
currency: price.currency, currency: price.currency,
description: price.nickname ?? null,
metadata: price.metadata,
type: price.type, type: price.type,
unit_amount: price.unit_amount ?? null, unit_amount: price.unit_amount ?? null,
interval: price.recurring?.interval ?? null, interval: price.recurring?.interval ?? null,
@@ -207,10 +211,9 @@ const manageSubscriptionStatusChange = async (
user_id: uuid, user_id: uuid,
metadata: subscription.metadata, metadata: subscription.metadata,
status: subscription.status, status: subscription.status,
product_id: subscription.items.data[0].price.product as string,
price_id: subscription.items.data[0].price.id, price_id: subscription.items.data[0].price.id,
//TODO check quantity on subscription quantity: 1, //subscription.quantity,
quantity: subscription.quantity,
cancel_at_period_end: subscription.cancel_at_period_end, cancel_at_period_end: subscription.cancel_at_period_end,
cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null, cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null,
canceled_at: subscription.canceled_at canceled_at: subscription.canceled_at
@@ -232,13 +235,67 @@ const manageSubscriptionStatusChange = async (
if (upsertError) throw new Error(`Subscription insert/update failed: ${upsertError.message}`); if (upsertError) throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`); console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
// For a new subscription copy the billing details to the customer object. // // For a new subscription copy the billing details to the customer object.
// NOTE: This is a costly operation and should happen at the very end. // // NOTE: This is a costly operation and should happen at the very end.
if (createAction && subscription.default_payment_method && uuid) // if (createAction && subscription.default_payment_method && uuid) {
await copyBillingDetailsToCustomer( // await copyBillingDetailsToCustomer(
uuid, // uuid,
subscription.default_payment_method as stripe.PaymentMethod // subscription.default_payment_method as stripe.PaymentMethod
); // );
// }
};
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
console.log(
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
);
try {
const { error } = await supabaseAdmin
.from('purchases')
.insert([{ user_id: userId, product_id: productId, price_id: priceId }]);
if (error) {
throw new Error(`Failed to record product purchase: ${error.message}`);
}
} catch (error) {
console.error(error);
}
console.log(`Product purchased recorded for user [${userId}] and product [${productId}]`);
};
const hasProductAccess = async (userId: string, productId: string) => {
// Check if user has purchased the product
const { data: purchases, error: purchaseError } = await supabaseAdmin
.from('purchases')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId);
if (purchaseError) throw new Error(purchaseError.message);
if (purchases && purchases.length > 0) {
// User has purchased the product
return true;
}
// Check if user has an active subscription for the product
const { data: subscriptions, error: subError } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('user_id', userId)
.eq('product_id', productId)
.in('status', ['active', 'trialing']);
if (subError) throw new Error(subError.message);
if (subscriptions && subscriptions.length > 0) {
// User has an active subscription
return true;
}
return false;
}; };
export { export {
@@ -247,5 +304,7 @@ export {
deleteProductRecord, deleteProductRecord,
deletePriceRecord, deletePriceRecord,
createOrRetrieveCustomer, createOrRetrieveCustomer,
manageSubscriptionStatusChange manageSubscriptionStatusChange,
recordProductPurchase,
hasProductAccess
}; };
+102 -83
View File
@@ -5,10 +5,10 @@
import { basket, type Basket, type Item } from '$lib/stores/basket.js'; import { basket, type Basket, type Item } from '$lib/stores/basket.js';
import { general } from '$lib/stores/generalStore.js'; import { general } from '$lib/stores/generalStore.js';
import { invalidate } from '$app/navigation'; import { invalidate } from '$app/navigation';
import SignIn from '$lib/components/SignIn.svelte';
import SignOut from '$lib/components/SignOut.svelte'; import SignOut from '$lib/components/SignOut.svelte';
import CookieConsent from '$lib/components/CookieConsent.svelte'; import CookieConsent from '$lib/components/CookieConsent.svelte';
import NavLinks from '$lib/components/NavLinks.svelte'; import NavLinks from '$lib/components/NavLinks.svelte';
import MagicLink from '$lib/components/MagicLink.svelte';
export let data; export let data;
let { supabase, session } = data; let { supabase, session } = data;
@@ -47,6 +47,12 @@
return () => data.subscription.unsubscribe(); return () => data.subscription.unsubscribe();
}); });
let isMobileMenuOpen = false;
function toggleMobileMenu() {
isMobileMenuOpen = !isMobileMenuOpen;
}
</script> </script>
<svelte:head> <svelte:head>
@@ -63,95 +69,108 @@
</svelte:head> </svelte:head>
<header> <header>
<nav class="navbar bg-primary text-primary-content"> <nav class="bg-primary text-primary-content p-4">
<div class="navbar-start"> <div class="container mx-auto flex justify-between items-center">
<div class="dropdown"> <a href="/" class="text-white font-bold text-xl">SvelteKit SaaS Boilerplate</a>
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg <!-- Hamburger Menu Button (Mobile) -->
xmlns="http://www.w3.org/2000/svg" <button on:click={toggleMobileMenu} class="block md:hidden text-white focus:outline-none">
class="h-5 w-5" <svg
fill="none" class="w-6 h-6"
viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke="currentColor"
><path viewBox="0 0 24 24"
stroke-linecap="round" xmlns="http://www.w3.org/2000/svg"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/></svg
>
</div>
<ul
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 text-base-content rounded-box w-52"
> >
<NavLinks isMobile={true} /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
></path>
</svg>
</button>
<!-- Navbar Links (hidden on mobile, visible on larger screens) -->
<div class="hidden md:flex space-x-4">
<ul class="flex flex-col md:flex-row md:space-x-4">
<NavLinks isMobile={false} />
</ul> </ul>
</div> </div>
<a href="/" class="btn btn-ghost text-xl">SvelteKit SaaS Boilerplate</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<NavLinks isMobile={false} />
</ul>
</div>
<div class="navbar-end">
<a href="/checkout" class="btn btn-ghost btn-circle">
<div class="indicator">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
/></svg
>
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
</div>
</a>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
{#if session?.user}
<div class="user-circle text-primary-content border-primary-content">
{session?.user.email[0].toUpperCase()}
</div>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fill-rule="evenodd"
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
</div>
{#if session?.user} <!-- Right Side: Basket & User Account -->
<ul <div class="flex items-center space-x-4">
tabindex="-1" <!-- Basket/Checkout Icon -->
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto" <a href="/checkout" class="btn btn-ghost btn-circle">
> <div class="indicator">
<li> <svg
<a href="/account"> Account </a> xmlns="http://www.w3.org/2000/svg"
</li> class="h-5 w-5"
<li><a href="/settings">Settings</a></li> fill="none"
<li><SignOut {supabase} /></li> viewBox="0 0 24 24"
</ul> stroke="currentColor"
{:else} >
<div <path
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto" stroke-linecap="round"
> stroke-linejoin="round"
<SignIn {supabase} /> stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
</div> </div>
{/if} </a>
<!-- User Account Dropdown -->
<details class="dropdown dropdown-end">
<summary tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
{#if session?.user}
<div class="user-circle text-primary-content border-primary-content">
{session?.user.email[0].toUpperCase()}
</div>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fill-rule="evenodd"
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
</summary>
{#if session?.user}
<ul
tabindex="-1"
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
>
<li><a href="/account">Account</a></li>
<li><a href="/settings">Settings</a></li>
<li><SignOut {supabase} /></li>
</ul>
{:else}
<div
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
>
<MagicLink {supabase} />
</div>
{/if}
</details>
</div> </div>
</div> </div>
<!-- Mobile Menu -->
<div
class="bg-base-100 text-base-content shadow rounded-box p-4 transition duration-300 ease-in-out md:hidden"
class:block={isMobileMenuOpen}
class:hidden={!isMobileMenuOpen}
>
<ul class="flex flex-col space-y-4">
<NavLinks isMobile={true} />
</ul>
</div>
</nav> </nav>
</header> </header>
+17 -9
View File
@@ -1,21 +1,29 @@
<script lang="ts"> <script lang="ts">
import SignUp from '$lib/components/SignUp.svelte'; import MagicLink from '$lib/components/MagicLink.svelte';
export let data; export let data;
let { supabase, session } = data; let { supabase, session } = data;
$: ({ supabase, session } = data); $: ({ supabase, session } = data);
</script> </script>
<div class="hero bg-base-100 my-36"> {#if session !== null}
<div class="hero-content flex-col lg:flex-row-reverse"> <div class="hero bg-base-100 my-36">
<div class="text-center lg:text-left"> Welcome back {session.user.email}
<h1 class="text-5xl font-bold">Sign up for this amazing SaaS right now - don't miss out!</h1> </div>
</div> {:else}
<div id="sign-up" class="card shrink-0 w-full max-w-sm shadow-2xl"> <div class="hero bg-base-100 my-36">
<SignUp {supabase} /> <div class="hero-content flex-col lg:flex-row-reverse">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">
Sign up for this amazing SaaS right now - don't miss out!
</h1>
</div>
<div id="sign-up" class="card shrink-0 w-full max-w-sm shadow-2xl">
<MagicLink {supabase} />
</div>
</div> </div>
</div> </div>
</div> {/if}
<div <div
class="md:container md:mx-auto justify-self-center flex justify-center items-center h-full flex-col mb-10" class="md:container md:mx-auto justify-self-center flex justify-center items-center h-full flex-col mb-10"
+13 -15
View File
@@ -8,41 +8,39 @@ export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession
redirect(303, '/'); redirect(303, '/');
} }
const { data: profile } = await supabase const { data: user } = await supabase
.from('profiles') .from('users')
.select(`username, full_name`) .select(`name`)
.eq('id', session.user.id) .eq('id', session.user.id)
.single(); .single();
return { session, profile }; return { session, user };
}; };
export const actions: Actions = { export const actions: Actions = {
update: async ({ request, locals: { supabase, safeGetSession } }) => { update: async ({ request, locals: { supabase, safeGetSession } }) => {
const formData = await request.formData(); const formData = await request.formData();
const fullName = formData.get('fullName') as string; const name = formData.get('name') as string;
const { session } = await safeGetSession(); const { session } = await safeGetSession();
const { error } = await supabase.from('profiles').upsert({ if (!session) {
id: session?.user.id, return fail(401, { name });
full_name: fullName, }
username: session?.user.email, const { error } = await supabase.from('users').update({
updated_at: new Date() name: name,
}); }).eq('id', session?.user.id);
console.error(error); console.error(error);
if (error) { if (error) {
return fail(500, { return fail(500, {
fullName, name,
username: session?.user.email
}); });
} }
return { return {
fullName, name,
username: session?.user.email
}; };
}, },
signout: async ({ locals: { supabase, safeGetSession } }) => { signout: async ({ locals: { supabase, safeGetSession } }) => {
+5 -10
View File
@@ -5,12 +5,12 @@
export let data; export let data;
export let form; export let form;
let { session, supabase, profile } = data; let { session, supabase, user } = data;
$: ({ session, supabase, profile } = data); $: ({ session, supabase, user } = data);
let profileForm: HTMLFormElement; let profileForm: HTMLFormElement;
let loading = false; let loading = false;
let fullName: string = profile?.full_name ?? ''; let name: string = user?.name ?? '';
const handleSubmit: SubmitFunction = () => { const handleSubmit: SubmitFunction = () => {
loading = true; loading = true;
@@ -37,13 +37,8 @@
bind:this={profileForm} bind:this={profileForm}
> >
<div> <div>
<label for="email">Username</label> <label for="name">Name</label>
<input id="email" type="text" value={session.user.email} disabled /> <input id="name" name="name" type="text" value={form?.name ?? name} />
</div>
<div>
<label for="fullName">Full Name</label>
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
</div> </div>
<div> <div>
+18 -9
View File
@@ -1,11 +1,17 @@
import { type RequestHandler, redirect } from '@sveltejs/kit'; import { type RequestHandler, redirect } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public'; import { stripe as stripeClient } from '$lib/utils/stripe';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
// Create a checkout session // Create a checkout session
export const POST: RequestHandler = async ({ request, cookies }) => { export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
const { session } = await safeGetSession();
if (!session) {
// If no session is found, the user isn't authenticated, so block the action
return new Response('Unauthorized', { status: 401 });
}
const userId = session.user.id;
const formData = new URLSearchParams(await request.text()); const formData = new URLSearchParams(await request.text());
const items: { priceId: string; quantity: number }[] = []; const items: { priceId: string; quantity: number }[] = [];
@@ -19,14 +25,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
quantity: item.quantity quantity: item.quantity
})); }));
const session = await stripeClient.checkout.sessions.create({ const checkoutSession = await stripeClient.checkout.sessions.create({
line_items: lineItems, line_items: lineItems,
mode: 'payment', mode: 'payment',
success_url: `${request.headers.get('origin')}/checkout/success`, success_url: `${request.headers.get('origin')}/checkout/success`,
cancel_url: `${request.headers.get('origin')}/checkout/cancelled` cancel_url: `${request.headers.get('origin')}/checkout/cancelled`,
metadata: {
userId: userId
}
}); });
// Store the checkout session.id for access from the frontend // Store the checkout session.id for access from the frontend
cookies.set('checkout_session_id', session.id, { path: '/' }); cookies.set('checkout_session_id', checkoutSession.id, { path: '/' });
return redirect(303, session.url as string); return redirect(303, checkoutSession.url as string);
}; };
+1 -3
View File
@@ -1,8 +1,6 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
import stripe from 'stripe';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY); import { stripe as stripeClient } from '$lib/utils/stripe';
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ cookies }) => {
const sessionId = cookies.get('checkout_session_id'); const sessionId = cookies.get('checkout_session_id');
+47 -27
View File
@@ -1,16 +1,15 @@
import type { RequestHandler } from '@sveltejs/kit'; import { json, type RequestHandler } from '@sveltejs/kit';
import stripe from 'stripe'; import stripe from 'stripe';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public'; import { STRIPE_ENDPOINT_SECRET } from '$env/static/private';
import { PUBLIC_STRIPE_ENDPOINT_SECRET } from '$env/static/public';
import { import {
deletePriceRecord, deletePriceRecord,
deleteProductRecord, deleteProductRecord,
manageSubscriptionStatusChange, manageSubscriptionStatusChange,
upsertPriceRecord, upsertPriceRecord,
upsertProductRecord upsertProductRecord,
recordProductPurchase
} from '$lib/utils/supabase/admin'; } from '$lib/utils/supabase/admin';
import { stripe as stripeClient } from '$lib/utils/stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const body = await request.text(); const body = await request.text();
@@ -23,13 +22,9 @@ export const POST: RequestHandler = async ({ request }) => {
// Only verify the event if you have an endpoint secret defined. // Only verify the event if you have an endpoint secret defined.
// Otherwise use the basic event deserialized with JSON.parse // Otherwise use the basic event deserialized with JSON.parse
if (PUBLIC_STRIPE_ENDPOINT_SECRET && signature) { if (STRIPE_ENDPOINT_SECRET && signature) {
try { try {
event = stripeClient.webhooks.constructEvent( event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
body,
signature,
PUBLIC_STRIPE_ENDPOINT_SECRET
);
} catch (err) { } catch (err) {
console.error(`⚠️ Webhook signature verification failed.`, err.message); console.error(`⚠️ Webhook signature verification failed.`, err.message);
return { return {
@@ -44,6 +39,9 @@ export const POST: RequestHandler = async ({ request }) => {
case 'product.updated': case 'product.updated':
await upsertProductRecord(event.data.object as stripe.Product); await upsertProductRecord(event.data.object as stripe.Product);
break; break;
case 'product.deleted':
await deleteProductRecord(event.data.object as stripe.Product);
break;
case 'price.created': case 'price.created':
case 'price.updated': case 'price.updated':
await upsertPriceRecord(event.data.object as stripe.Price); await upsertPriceRecord(event.data.object as stripe.Price);
@@ -51,12 +49,11 @@ export const POST: RequestHandler = async ({ request }) => {
case 'price.deleted': case 'price.deleted':
await deletePriceRecord(event.data.object as stripe.Price); await deletePriceRecord(event.data.object as stripe.Price);
break; break;
case 'product.deleted':
await deleteProductRecord(event.data.object as stripe.Product);
break;
case 'customer.subscription.created': case 'customer.subscription.created':
case 'customer.subscription.updated': case 'customer.subscription.updated':
case 'customer.subscription.deleted': { case 'customer.subscription.deleted':
case 'customer.subscription.paused':
case 'customer.subscription.resumed': {
const subscription = event.data.object as stripe.Subscription; const subscription = event.data.object as stripe.Subscription;
await manageSubscriptionStatusChange( await manageSubscriptionStatusChange(
subscription.id, subscription.id,
@@ -67,6 +64,7 @@ export const POST: RequestHandler = async ({ request }) => {
} }
case 'checkout.session.completed': { case 'checkout.session.completed': {
const checkoutSession = event.data.object as stripe.Checkout.Session; const checkoutSession = event.data.object as stripe.Checkout.Session;
if (checkoutSession.mode === 'subscription') { if (checkoutSession.mode === 'subscription') {
const subscriptionId = checkoutSession.subscription; const subscriptionId = checkoutSession.subscription;
await manageSubscriptionStatusChange( await manageSubscriptionStatusChange(
@@ -74,22 +72,44 @@ export const POST: RequestHandler = async ({ request }) => {
checkoutSession.customer as string, checkoutSession.customer as string,
true true
); );
} else if (checkoutSession.mode === 'payment') {
// Fetch session with expanded line_items
const sessionWithLineItems = await stripeClient.checkout.sessions.retrieve(
checkoutSession.id,
{
expand: ['line_items']
}
);
const lineItems = sessionWithLineItems.line_items?.data;
const userId = checkoutSession.metadata!.userId;
if (!lineItems) {
throw new Error('No line items found in session');
}
if (!userId) {
throw new Error('No user ID found in session metadata');
}
for (const item of lineItems ?? []) {
const productId = item.price?.product as string;
const priceId = item.price?.id as string;
if (productId && priceId) {
await recordProductPurchase(userId, productId, priceId);
}
}
} }
break; break;
} }
default: default:
console.error(`Unhandled event type ${event.type}`); console.log(`Unhandled event type ${event.type}`);
} }
// Return a response to acknowledge receipt of the event
return new Response(JSON.stringify({ received: true }), {
status: 200
});
} catch (err) { } catch (err) {
console.log(`Webhook Error: ${err.message}`); const message = err instanceof Error ? err.message : 'An unknown error has occurred';
return { return new Response(`Webhook Error: ${message}`, { status: 500 });
status: 400,
body: `Webhook Error: ${err.message}`
};
} }
return json({ received: true });
}; };
+14
View File
@@ -0,0 +1,14 @@
import { redirect, type RequestHandler } from '@sveltejs/kit'
export const GET: RequestHandler = async (event) => {
const { url, locals: { supabase } } = event;
const code = url.searchParams.get('code')
if (code) {
await supabase.auth.exchangeCodeForSession(code)
}
// TODO: Create stripe customer and sync with supabase user
throw redirect(303, '/account')
}
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('./$types').PageLoad} */
import { redirect } from '@sveltejs/kit';
export const load = async ({ params, locals: { safeGetSession } }) => {
const { session } = await safeGetSession();
if (!session) {
throw redirect(303, '/login'); // Redirect unauthenticated users to login
}
return params;
};
+30 -22
View File
@@ -3,6 +3,10 @@
import { basket, type Basket, type Item } from '$lib/stores/basket.js'; import { basket, type Basket, type Item } from '$lib/stores/basket.js';
import BasketItem from '$lib/components/checkout/BasketItem.svelte'; import BasketItem from '$lib/components/checkout/BasketItem.svelte';
export let data;
let { supabase, session, url } = data;
$: ({ supabase, session, url } = data);
let localBasket: Basket; let localBasket: Basket;
const unsubscribe = basket.subscribe((value) => { const unsubscribe = basket.subscribe((value) => {
localBasket = value; localBasket = value;
@@ -101,28 +105,32 @@
/> />
</svg> </svg>
</a> </a>
<button {#if session?.user !== undefined}
type="submit" <button
class="btn btn-primary" type="submit"
disabled={localBasket.items && localBasket.items.length === 0} class="btn btn-primary"
>Continue to Payment disabled={localBasket.items && localBasket.items.length === 0}
<svg >Continue to Payment
class="ml-2" <svg
xmlns="http://www.w3.org/2000/svg" class="ml-2"
width="23" xmlns="http://www.w3.org/2000/svg"
height="22" width="23"
viewBox="0 0 23 22" height="22"
fill="none" viewBox="0 0 23 22"
> fill="none"
<path >
d="M8.75324 5.49609L14.2535 10.9963L8.75 16.4998" <path
stroke="white" d="M8.75324 5.49609L14.2535 10.9963L8.75 16.4998"
stroke-width="1.6" stroke="white"
stroke-linecap="round" stroke-width="1.6"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
</svg> />
</button> </svg>
</button>
{:else}
<p>You must sign in to checkout</p>
{/if}
</div> </div>
</form> </form>
</div> </div>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import MagicLink from '$lib/components/MagicLink.svelte';
export let data;
let { supabase, session, url } = data;
$: ({ supabase, session, url } = data);
</script>
<MagicLink {supabase} />
+1 -3
View File
@@ -1,7 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public'; import { stripe as stripeClient } from '$lib/utils/stripe';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
export const GET = async () => { export const GET = async () => {
const products = await stripeClient.products.list({ const products = await stripeClient.products.list({
+1 -3
View File
@@ -1,7 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public'; import { stripe as stripeClient } from '$lib/utils/stripe';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
export const GET = async ({ params }) => { export const GET = async ({ params }) => {
const { productId } = params; const { productId } = params;
@@ -1,7 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public'; import { stripe as stripeClient } from '$lib/utils/stripe';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
export const GET = async ({ params }) => { export const GET = async ({ params }) => {
const { priceId } = params; const { priceId } = params;
@@ -0,0 +1,19 @@
/** @type {import('./$types').PageLoad} */
import { redirect } from '@sveltejs/kit';
import { hasProductAccess } from '$lib/utils/supabase/admin';
import { VITE_PRODUCT_ID_EXAMPLEPRODUCT } from '$env/static/private'; // Import the example product ID from env
export const load = async ({ params, locals: { safeGetSession } }) => {
const { session } = await safeGetSession();
if (!session) {
throw redirect(303, '/login'); // Redirect unauthenticated users to login
}
// Check if the user has access to the 'EXAMPLE PRODUCT' product
const accessGranted = await hasProductAccess(session.user.id, VITE_PRODUCT_ID_EXAMPLEPRODUCT);
if (!accessGranted) {
throw redirect(303, '/unauthorised');
}
return params;
};
@@ -0,0 +1,2 @@
<h1>Example Product</h1>
<p>This is an example of a tool you could be selling.</p>
+2
View File
@@ -0,0 +1,2 @@
<h1>Unauthorised Access</h1>
<p>Sorry, you don't have permission to access this tool.</p>
+5 -10
View File
@@ -49,8 +49,6 @@ enabled = true
port = 54323 port = 54323
# External URL of the API server that frontend connects to. # External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1" api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface. # are monitored, and you can view the emails that would have been sent from the web interface.
@@ -67,16 +65,13 @@ enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB"). # The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB" file_size_limit = "50MiB"
[storage.image_transformation]
enabled = true
[auth] [auth]
enabled = true enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails. # in emails.
site_url = "http://127.0.0.1:3000" site_url = "http://localhost:5173"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. # A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"] additional_redirect_urls = ["http://localhost:5173/auth/confirm", "http://localhost:5173/auth/callback"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600 jwt_expiry = 3600
# If disabled, the refresh token will never expire. # If disabled, the refresh token will never expire.
@@ -112,9 +107,9 @@ max_frequency = "1s"
# sender_name = "Admin" # sender_name = "Admin"
# Uncomment to customize email template # Uncomment to customize email template
# [auth.email.template.invite] [auth.email.template.magic_link]
# subject = "You have been invited" subject = "Sign in to your account"
# content_path = "./supabase/templates/invite.html" content_path = "./supabase/templates/magic_link.html"
[auth.sms] [auth.sms]
# Allow/disallow new user signups via SMS to your project. # Allow/disallow new user signups via SMS to your project.
@@ -5,38 +5,26 @@
create table users ( create table users (
-- UUID from auth.users -- UUID from auth.users
id uuid references auth.users not null primary key, id uuid references auth.users not null primary key,
full_name text, name text
avatar_url text,
-- The customer's billing address, stored in JSON format.
billing_address jsonb,
-- Stores your customer's payment instruments.
payment_method jsonb
); );
alter table users alter table users enable row level security;
enable row level security; create policy "Can view own user data." on users for select using (auth.uid() = id);
create policy "Can view own user data." on users create policy "Can update own user data." on users for update using (auth.uid() = id);
for select using ((select auth.uid()) = id);
create policy "Can update own user data." on users
for update using ((select auth.uid()) = id);
/** /**
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth. * This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
*/ */
create function public.handle_new_user() create function public.handle_new_user()
returns trigger as returns trigger as $$
$$ begin
begin insert into public.users (id, name)
insert into public.users (id, full_name, avatar_url) values (new.id, new.raw_user_meta_data->>'name');
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); return new;
return new; end;
end; $$ language plpgsql security definer;
$$
language plpgsql security definer;
create trigger on_auth_user_created create trigger on_auth_user_created
after insert on auth.users after insert on auth.users
for each row for each row execute procedure public.handle_new_user();
execute procedure public.handle_new_user();
/** /**
* CUSTOMERS * CUSTOMERS
@@ -64,15 +52,17 @@ create table products (
name text, name text,
-- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes. -- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.
description text, description text,
-- A URL of the product image in Stripe, meant to be displayable to the customer. -- URLs of the product images in Stripe, meant to be displayable to the customer.
image text, images text[],
-- A list of up to 15 features for this product. These are displayed in pricing tables.
features text[],
-- Set of key-value pairs, used to store additional information about the object in a structured format. -- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb metadata jsonb,
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone not null default now()
); );
alter table products alter table products enable row level security;
enable row level security; create policy "Allow public read-only access." on products for select using (true);
create policy "Allow public read-only access." on products
for select using (true);
/** /**
* PRICES * PRICES
@@ -83,8 +73,8 @@ create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
create table prices ( create table prices (
-- Price ID from Stripe, e.g. price_1234. -- Price ID from Stripe, e.g. price_1234.
id text primary key, id text primary key,
-- The ID of the prduct that this price belongs to. -- The ID of the product that this price belongs to.
product_id text references products, product_id text not null references products,
-- Whether the price can be used for new purchases. -- Whether the price can be used for new purchases.
active boolean, active boolean,
-- A brief description of the price. -- A brief description of the price.
@@ -104,16 +94,14 @@ create table prices (
-- Set of key-value pairs, used to store additional information about the object in a structured format. -- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb metadata jsonb
); );
alter table prices alter table prices enable row level security;
enable row level security; create policy "Allow public read-only access." on prices for select using (true);
create policy "Allow public read-only access." on prices
for select using (true);
/** /**
* SUBSCRIPTIONS * SUBSCRIPTIONS
* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks. * Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/ */
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'); create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
create table subscriptions ( create table subscriptions (
-- Subscription ID from Stripe, e.g. sub_1234. -- Subscription ID from Stripe, e.g. sub_1234.
id text primary key, id text primary key,
@@ -122,6 +110,8 @@ create table subscriptions (
status subscription_status, status subscription_status,
-- Set of key-value pairs, used to store additional information about the object in a structured format. -- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb, metadata jsonb,
-- ID of the product this subscription is for.
product_id text references products(id) not null,
-- ID of the price that created this subscription. -- ID of the price that created this subscription.
price_id text references prices, price_id text references prices,
-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats. -- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
@@ -145,15 +135,21 @@ create table subscriptions (
-- If the subscription has a trial, the end of that trial. -- If the subscription has a trial, the end of that trial.
trial_end timestamp with time zone default timezone('utc'::text, now()) trial_end timestamp with time zone default timezone('utc'::text, now())
); );
alter table subscriptions alter table subscriptions enable row level security;
enable row level security; create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);
create policy "Can only view own subs data." on subscriptions
for select using ((select auth.uid()) = user_id);
/** -- Purchases table to record product purchases.
* REALTIME SUBSCRIPTIONS create table purchases (
* Only allow realtime listening on public tables. id uuid default uuid_generate_v4() primary key,
*/ user_id uuid references auth.users not null,
drop publication if exists supabase_realtime; product_id text references products(id) not null,
create publication supabase_realtime price_id text references prices(id) not null,
for table products, prices; purchased_at timestamp with time zone not null default now()
);
-- Enabling row-level security for purchases.
alter table purchases enable row level security;
-- Creating a policy to ensure that users can only view their own purchase data.
create policy "Can view own purchase data." on purchases for select using (auth.uid() = user_id);
create policy "Can insert own purchase data." on purchases for insert with check (auth.uid() = user_id);
+6
View File
@@ -0,0 +1,6 @@
<h1>Sign in to your account</h1>
<p>
Click this link to log in:
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"> Log In </a>
</p>
+1 -1
View File
@@ -12,7 +12,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"module": "es2015" "module": "es2015"
}, },
"include": ["src/**/*", "node_modules/**/*.d.ts"] "include": ["src/**/*", "node_modules/**/*.d.ts", ".svelte-kit/ambient.d.ts"]
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// //
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes