Skip to content

Commit

Permalink
Stripe checkout session sync (#86)
Browse files Browse the repository at this point in the history
* add checkout_sessions table

* make supabase webhook actually work

* use the latest subscription rather than returning an error if multiple subscriptions
  • Loading branch information
matthewwong525 authored Aug 4, 2024
1 parent 0ff615f commit 011490e
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 6 deletions.
5 changes: 4 additions & 1 deletion flutter/lib/services/metadata_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ class Metadata extends _$Metadata {
final res = await client
.from('subscriptions')
.select('*, prices(*, products(*))')
.inFilter('status', ['trialing', 'active']).maybeSingle();
.inFilter('status', ['trialing', 'active'])
.order('created', ascending: false)
.limit(1)
.maybeSingle();
return (res == null) ? null : SubscriptionWithPrice.fromJson(res);
}
}
60 changes: 60 additions & 0 deletions nextjs/types_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,63 @@ export type Json =
export type Database = {
public: {
Tables: {
checkout_sessions: {
Row: {
created: string
id: string
metadata: Json | null
mode: Database["public"]["Enums"]["checkout_mode"] | null
payment_status:
| Database["public"]["Enums"]["checkout_payment_status"]
| null
price_id: string | null
quantity: number | null
status: Database["public"]["Enums"]["checkout_status"] | null
user_id: string
}
Insert: {
created?: string
id: string
metadata?: Json | null
mode?: Database["public"]["Enums"]["checkout_mode"] | null
payment_status?:
| Database["public"]["Enums"]["checkout_payment_status"]
| null
price_id?: string | null
quantity?: number | null
status?: Database["public"]["Enums"]["checkout_status"] | null
user_id: string
}
Update: {
created?: string
id?: string
metadata?: Json | null
mode?: Database["public"]["Enums"]["checkout_mode"] | null
payment_status?:
| Database["public"]["Enums"]["checkout_payment_status"]
| null
price_id?: string | null
quantity?: number | null
status?: Database["public"]["Enums"]["checkout_status"] | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "checkout_sessions_price_id_fkey"
columns: ["price_id"]
isOneToOne: false
referencedRelation: "prices"
referencedColumns: ["id"]
},
{
foreignKeyName: "checkout_sessions_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
customers: {
Row: {
id: string
Expand Down Expand Up @@ -218,6 +275,9 @@ export type Database = {
[_ in never]: never
}
Enums: {
checkout_mode: "payment" | "setup" | "subscription"
checkout_payment_status: "paid" | "unpaid" | "no_payment_required"
checkout_status: "complete" | "expired" | "open"
pricing_plan_interval: "day" | "week" | "month" | "year"
pricing_type: "one_time" | "recurring"
subscription_status:
Expand Down
2 changes: 2 additions & 0 deletions nextjs/utils/supabase/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const getSubscription = cache(async (supabase: SupabaseClient) => {
.from('subscriptions')
.select('*, prices(*, products(*))')
.in('status', ['trialing', 'active'])
.order('created', { ascending: false })
.limit(1)
.maybeSingle();

return subscription;
Expand Down
55 changes: 52 additions & 3 deletions supabase/functions/_shared/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,8 @@ const createOrRetrieveCustomer = async ({
} else {
// If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by email
const stripeCustomers = await stripe.customers.list({ email: email });
stripeCustomerId = stripeCustomers.data.length > 0
? stripeCustomers.data[0].id
: undefined;
stripeCustomerId =
stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined;
}

// If still no stripeCustomerId, create a new customer in Stripe
Expand Down Expand Up @@ -299,10 +298,60 @@ const manageSubscriptionStatusChange = async (
}
};

const insertCheckoutSession = async (
webhookSession: Stripe.Checkout.Session,
) => {
// Get customer's UUID from mapping table.
const { data: customerData, error: noCustomerError } = await supabase
.from("customers")
.select("id")
.eq("stripe_customer_id", webhookSession.customer)
.single();

if (noCustomerError) {
throw new Error(`Customer lookup failed: ${noCustomerError.message}`);
}

const checkoutSession = await stripe.checkout.sessions.retrieve(
webhookSession.id,
{
expand: ["line_items"],
},
);

const { id: uuid } = customerData!;

// Upsert the latest status of the subscription object.
const sessionData: TablesInsert<"checkout_sessions"> = {
id: checkoutSession.id,
user_id: uuid,
metadata: checkoutSession.metadata,
mode: checkoutSession.mode,
status: checkoutSession.status,
payment_status: checkoutSession.payment_status,
price_id: checkoutSession.line_items.data[0].price.id,
//TODO check quantity on subscription
// @ts-ignore: ignore quantity doesnt exist
quantity: checkoutSession.line_items.data[0].quantity,
created: toDateTime(checkoutSession.created).toISOString(),
};

const { error: insertError } = await supabase
.from("checkout_sessions")
.insert(sessionData);
if (insertError) {
throw new Error(`Checkout session insert failed: ${insertError.message}`);
}
console.log(
`Inserted checkout session [${checkoutSession.id}] for user [${uuid}]`,
);
};

export {
createOrRetrieveCustomer,
deletePriceRecord,
deleteProductRecord,
insertCheckoutSession,
manageSubscriptionStatusChange,
upsertPriceRecord,
upsertProductRecord,
Expand Down
6 changes: 4 additions & 2 deletions supabase/functions/stripe_webhook/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { processWebhookRequest, stripe } from "../_shared/stripe.ts";
import { processWebhookRequest } from "../_shared/stripe.ts";
import Stripe from "stripe";
import {
deletePriceRecord,
deleteProductRecord,
insertCheckoutSession,
manageSubscriptionStatusChange,
upsertPriceRecord,
upsertProductRecord,
Expand Down Expand Up @@ -60,6 +61,7 @@ Deno.serve(async (req) => {
}
case "checkout.session.completed": {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
await insertCheckoutSession(checkoutSession);
if (checkoutSession.mode === "subscription") {
const subscriptionId = checkoutSession.subscription;
await manageSubscriptionStatusChange(
Expand All @@ -76,7 +78,7 @@ Deno.serve(async (req) => {
} catch (error) {
console.log(error);
return new Response(
"Webhook handler failed. View your Next.js function logs.",
"Webhook handler failed. View your supabase function logs.",
{
status: 400,
},
Expand Down
60 changes: 60 additions & 0 deletions supabase/functions/types_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,63 @@ export type Json =
export type Database = {
public: {
Tables: {
checkout_sessions: {
Row: {
created: string
id: string
metadata: Json | null
mode: Database["public"]["Enums"]["checkout_mode"] | null
payment_status:
| Database["public"]["Enums"]["checkout_payment_status"]
| null
price_id: string | null
quantity: number | null
status: Database["public"]["Enums"]["checkout_status"] | null
user_id: string
}
Insert: {
created?: string
id: string
metadata?: Json | null
mode?: Database["public"]["Enums"]["checkout_mode"] | null
payment_status?:
| Database["public"]["Enums"]["checkout_payment_status"]
| null
price_id?: string | null
quantity?: number | null
status?: Database["public"]["Enums"]["checkout_status"] | null
user_id: string
}
Update: {
created?: string
id?: string
metadata?: Json | null
mode?: Database["public"]["Enums"]["checkout_mode"] | null
payment_status?:
| Database["public"]["Enums"]["checkout_payment_status"]
| null
price_id?: string | null
quantity?: number | null
status?: Database["public"]["Enums"]["checkout_status"] | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "checkout_sessions_price_id_fkey"
columns: ["price_id"]
isOneToOne: false
referencedRelation: "prices"
referencedColumns: ["id"]
},
{
foreignKeyName: "checkout_sessions_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
customers: {
Row: {
id: string
Expand Down Expand Up @@ -218,6 +275,9 @@ export type Database = {
[_ in never]: never
}
Enums: {
checkout_mode: "payment" | "setup" | "subscription"
checkout_payment_status: "paid" | "unpaid" | "no_payment_required"
checkout_status: "complete" | "expired" | "open"
pricing_plan_interval: "day" | "week" | "month" | "year"
pricing_type: "one_time" | "recurring"
subscription_status:
Expand Down
29 changes: 29 additions & 0 deletions supabase/migrations/20240717231009_init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,35 @@ create table subscriptions (
alter table subscriptions enable row level security;
create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);

/**
* CHECKOUT SESSIONS
* Note: Completed checkouts are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create type checkout_mode as enum ('payment', 'setup', 'subscription');
create type checkout_status as enum ('complete', 'expired', 'open');
create type checkout_payment_status as enum ('paid', 'unpaid', 'no_payment_required');
create table checkout_sessions (
-- Checkout Session ID from Stripe, e.g. cs_1234.
id text primary key,
user_id uuid references auth.users not null,
-- The mode of the Checkout Session.
mode checkout_mode,
-- The payment status of the Checkout Session
payment_status checkout_payment_status,
-- The status of the Checkout Session
status checkout_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 checkout.
price_id text references prices,
-- Quantity multiplied by the unit amount of the price creates the amount of the item.
quantity integer,
-- Time at which the checkout session was created.
created timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table checkout_sessions enable row level security;
create policy "Can only view own checkout session data" on checkout_sessions for select using (auth.uid() = user_id);

/**
* REALTIME SUBSCRIPTIONS
* Only allow realtime listening on public tables.
Expand Down

0 comments on commit 011490e

Please sign in to comment.