mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-13 02:53:50 +00:00
feat(#14): Add db methods
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import stripe from 'stripe';
|
||||
import type { Database, Tables, TablesInsert } from '$types/supabase';
|
||||
import {
|
||||
PUBLIC_SUPABASE_URL,
|
||||
SUPABASE_SERVICE_ROLE_KEY,
|
||||
PUBLIC_STRIPE_SECRET_KEY
|
||||
} from '$env/static/public';
|
||||
|
||||
const toDateTime = (secs: number) => {
|
||||
const t = new Date(+0); // Unix epoch start.
|
||||
t.setSeconds(secs);
|
||||
return t;
|
||||
};
|
||||
|
||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
||||
|
||||
type Product = Tables<'products'>;
|
||||
type Price = Tables<'prices'>;
|
||||
|
||||
// Change to control trial period length
|
||||
const TRIAL_PERIOD_DAYS = 0;
|
||||
|
||||
// Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context
|
||||
// as it has admin privileges and overwrites RLS policies!
|
||||
const supabaseAdmin = createClient<Database>(
|
||||
PUBLIC_SUPABASE_URL || '',
|
||||
SUPABASE_SERVICE_ROLE_KEY || ''
|
||||
);
|
||||
|
||||
const upsertProductRecord = async (product: stripe.Product) => {
|
||||
const productData: Product = {
|
||||
id: product.id,
|
||||
active: product.active,
|
||||
name: product.name,
|
||||
description: product.description ?? null,
|
||||
image: product.images?.[0] ?? null,
|
||||
metadata: product.metadata
|
||||
};
|
||||
|
||||
const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]);
|
||||
if (upsertError) throw new Error(`Product insert/update failed: ${upsertError.message}`);
|
||||
console.log(`Product inserted/updated: ${product.id}`);
|
||||
};
|
||||
|
||||
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
|
||||
const priceData: Price = {
|
||||
id: price.id,
|
||||
product_id: typeof price.product === 'string' ? price.product : '',
|
||||
active: price.active,
|
||||
currency: price.currency,
|
||||
type: price.type,
|
||||
unit_amount: price.unit_amount ?? null,
|
||||
interval: price.recurring?.interval ?? null,
|
||||
interval_count: price.recurring?.interval_count ?? null,
|
||||
trial_period_days: price.recurring?.trial_period_days ?? TRIAL_PERIOD_DAYS
|
||||
};
|
||||
|
||||
const { error: upsertError } = await supabaseAdmin.from('prices').upsert([priceData]);
|
||||
|
||||
if (upsertError?.message.includes('foreign key constraint')) {
|
||||
if (retryCount < maxRetries) {
|
||||
console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await upsertPriceRecord(price, retryCount + 1, maxRetries);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Price insert/update failed after ${maxRetries} retries: ${upsertError.message}`
|
||||
);
|
||||
}
|
||||
} else if (upsertError) {
|
||||
throw new Error(`Price insert/update failed: ${upsertError.message}`);
|
||||
} else {
|
||||
console.log(`Price inserted/updated: ${price.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProductRecord = async (product: stripe.Product) => {
|
||||
const { error: deletionError } = await supabaseAdmin
|
||||
.from('products')
|
||||
.delete()
|
||||
.eq('id', product.id);
|
||||
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
|
||||
console.log(`Product deleted: ${product.id}`);
|
||||
};
|
||||
|
||||
const deletePriceRecord = async (price: stripe.Price) => {
|
||||
const { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
|
||||
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
|
||||
console.log(`Price deleted: ${price.id}`);
|
||||
};
|
||||
|
||||
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
|
||||
const { error: upsertError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.upsert([{ id: uuid, stripe_customer_id: customerId }]);
|
||||
|
||||
if (upsertError)
|
||||
throw new Error(`Supabase customer record creation failed: ${upsertError.message}`);
|
||||
|
||||
return customerId;
|
||||
};
|
||||
|
||||
const createCustomerInStripe = async (uuid: string, email: string) => {
|
||||
const customerData = { metadata: { supabaseUUID: uuid }, email: email };
|
||||
const newCustomer = await stripeClient.customers.create(customerData);
|
||||
if (!newCustomer) throw new Error('Stripe customer creation failed.');
|
||||
|
||||
return newCustomer.id;
|
||||
};
|
||||
|
||||
const createOrRetrieveCustomer = async ({ email, uuid }: { email: string; uuid: string }) => {
|
||||
// Check if the customer already exists in Supabase
|
||||
const { data: existingSupabaseCustomer, error: queryError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', uuid)
|
||||
.maybeSingle();
|
||||
|
||||
if (queryError) {
|
||||
throw new Error(`Supabase customer lookup failed: ${queryError.message}`);
|
||||
}
|
||||
|
||||
// Retrieve the Stripe customer ID using the Supabase customer ID, with email fallback
|
||||
let stripeCustomerId: string | undefined;
|
||||
if (existingSupabaseCustomer?.stripe_customer_id) {
|
||||
const existingStripeCustomer = await stripeClient.customers.retrieve(
|
||||
existingSupabaseCustomer.stripe_customer_id
|
||||
);
|
||||
stripeCustomerId = existingStripeCustomer.id;
|
||||
} else {
|
||||
// If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by email
|
||||
const stripeCustomers = await stripeClient.customers.list({ email: email });
|
||||
stripeCustomerId = stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined;
|
||||
}
|
||||
|
||||
// If still no stripeCustomerId, create a new customer in Stripe
|
||||
const stripeIdToInsert = stripeCustomerId
|
||||
? stripeCustomerId
|
||||
: await createCustomerInStripe(uuid, email);
|
||||
if (!stripeIdToInsert) throw new Error('Stripe customer creation failed.');
|
||||
|
||||
if (existingSupabaseCustomer && stripeCustomerId) {
|
||||
// If Supabase has a record but doesn't match Stripe, update Supabase record
|
||||
if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) {
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.update({ stripe_customer_id: stripeCustomerId })
|
||||
.eq('id', uuid);
|
||||
|
||||
if (updateError)
|
||||
throw new Error(`Supabase customer record update failed: ${updateError.message}`);
|
||||
console.warn(`Supabase customer record mismatched Stripe ID. Supabase record updated.`);
|
||||
}
|
||||
// If Supabase has a record and matches Stripe, return Stripe customer ID
|
||||
return stripeCustomerId;
|
||||
} else {
|
||||
console.warn(`Supabase customer record was missing. A new record was created.`);
|
||||
|
||||
// If Supabase has no record, create a new record and return Stripe customer ID
|
||||
const upsertedStripeCustomer = await upsertCustomerToSupabase(uuid, stripeIdToInsert);
|
||||
if (!upsertedStripeCustomer) throw new Error('Supabase customer record creation failed.');
|
||||
|
||||
return upsertedStripeCustomer;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copies the billing details from the payment method to the customer object.
|
||||
*/
|
||||
const copyBillingDetailsToCustomer = async (uuid: string, payment_method: stripe.PaymentMethod) => {
|
||||
//Todo: check this assertion
|
||||
const customer = payment_method.customer as string;
|
||||
const { name, phone, address } = payment_method.billing_details;
|
||||
if (!name || !phone || !address) return;
|
||||
|
||||
await stripeClient.customers.update(customer, { name, phone, address });
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from('users')
|
||||
.update({
|
||||
billing_address: { ...address },
|
||||
payment_method: { ...payment_method[payment_method.type] }
|
||||
})
|
||||
.eq('id', uuid);
|
||||
if (updateError) throw new Error(`Customer update failed: ${updateError.message}`);
|
||||
};
|
||||
|
||||
const manageSubscriptionStatusChange = async (
|
||||
subscriptionId: string,
|
||||
customerId: string,
|
||||
createAction = false
|
||||
) => {
|
||||
// Get customer's UUID from mapping table.
|
||||
const { data: customerData, error: noCustomerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('stripe_customer_id', customerId)
|
||||
.single();
|
||||
|
||||
if (noCustomerError) throw new Error(`Customer lookup failed: ${noCustomerError.message}`);
|
||||
|
||||
const { id: uuid } = customerData!;
|
||||
|
||||
const subscription = await stripeClient.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['default_payment_method']
|
||||
});
|
||||
// Upsert the latest status of the subscription object.
|
||||
const subscriptionData: TablesInsert<'subscriptions'> = {
|
||||
id: subscription.id,
|
||||
user_id: uuid,
|
||||
metadata: subscription.metadata,
|
||||
status: subscription.status,
|
||||
price_id: subscription.items.data[0].price.id,
|
||||
//TODO check quantity on subscription
|
||||
|
||||
quantity: subscription.quantity,
|
||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||
cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null,
|
||||
canceled_at: subscription.canceled_at
|
||||
? toDateTime(subscription.canceled_at).toISOString()
|
||||
: null,
|
||||
current_period_start: toDateTime(subscription.current_period_start).toISOString(),
|
||||
current_period_end: toDateTime(subscription.current_period_end).toISOString(),
|
||||
created: toDateTime(subscription.created).toISOString(),
|
||||
ended_at: subscription.ended_at ? toDateTime(subscription.ended_at).toISOString() : null,
|
||||
trial_start: subscription.trial_start
|
||||
? toDateTime(subscription.trial_start).toISOString()
|
||||
: null,
|
||||
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
|
||||
};
|
||||
|
||||
const { error: upsertError } = await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.upsert([subscriptionData]);
|
||||
if (upsertError) throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
|
||||
console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
|
||||
|
||||
// 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.
|
||||
if (createAction && subscription.default_payment_method && uuid)
|
||||
await copyBillingDetailsToCustomer(
|
||||
uuid,
|
||||
subscription.default_payment_method as stripe.PaymentMethod
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
upsertProductRecord,
|
||||
upsertPriceRecord,
|
||||
deleteProductRecord,
|
||||
deletePriceRecord,
|
||||
createOrRetrieveCustomer,
|
||||
manageSubscriptionStatusChange
|
||||
};
|
||||
Reference in New Issue
Block a user