mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-13 02:53:50 +00:00
Merge remote-tracking branch 'origin/master' into 35-replace-navbar-with-a-better-one
This commit is contained in:
@@ -26,7 +26,9 @@
|
||||
>
|
||||
<p class="text-sm">
|
||||
We use cookies to ensure you get the best experience on our website. By continuing, you agree
|
||||
to our use of cookies.
|
||||
to our use of cookies. For details, please see our <a href="/privacy" class="underline"
|
||||
>Privacy Policy</a
|
||||
>.
|
||||
</p>
|
||||
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={acceptCookies}
|
||||
>Accept</button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { signOut } from '$lib/utils/supabase/auth';
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function emitSignedOutEvent() {
|
||||
@@ -9,13 +10,39 @@
|
||||
}
|
||||
|
||||
// Access the supabase client from the layout data
|
||||
export let supabase: SupabaseClient | null;
|
||||
export let supabase: SupabaseClient;
|
||||
|
||||
async function signOutClick() {
|
||||
if (!supabase) return;
|
||||
await signOut(supabase, emitSignedOutEvent);
|
||||
let localBasket: Basket;
|
||||
const unsubscribe = basket.subscribe((value) => {
|
||||
localBasket = value;
|
||||
});
|
||||
|
||||
onDestroy(unsubscribe);
|
||||
|
||||
async function signOut() {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error('Error signing out:', error.message);
|
||||
alert('Error signing out');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
emitSignedOutEvent();
|
||||
|
||||
// Clear the basket
|
||||
localBasket = { items: [], subtotal: 0 };
|
||||
basket.set(localBasket);
|
||||
unsubscribe();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('basket');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error clearing basket:', err);
|
||||
alert('Error clearing basket');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={signOutClick} aria-label="Sign out of your account" type="button">Sign Out</button
|
||||
>
|
||||
<button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
|
||||
// Access the supabase client from the layout data
|
||||
export let supabase: any;
|
||||
|
||||
async function signUpNewUser() {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email,
|
||||
password: password,
|
||||
options: {
|
||||
// Redirect URL after successful sign-up
|
||||
redirectTo: '/account'
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle success (optional)
|
||||
} catch (error: any) {
|
||||
console.error('Sign up error:', error.message);
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4 opacity-70"
|
||||
><path
|
||||
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
|
||||
/><path
|
||||
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
|
||||
/>
|
||||
</svg>
|
||||
<input type="text" class="grow" placeholder="Email" required bind:value={email} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4 opacity-70"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<input type="password" class="grow" placeholder="Password" required bind:value={password} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control mt-6">
|
||||
<button class="btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -11,10 +11,11 @@
|
||||
export let price = 0;
|
||||
export let quantity = 0;
|
||||
|
||||
$: dispatch('changeQuantity', { itemId, quantity });
|
||||
|
||||
function reduceQuantityByOne() {
|
||||
if (quantity > 1) {
|
||||
quantity--;
|
||||
dispatch('changeQuantity', { itemId, quantity });
|
||||
} else {
|
||||
dispatch('removeItem', { itemId });
|
||||
}
|
||||
@@ -22,7 +23,6 @@
|
||||
|
||||
function increaseQuantityByOne() {
|
||||
quantity++;
|
||||
dispatch('changeQuantity', { itemId, quantity });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Vendored
+26
@@ -34,6 +34,32 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
account_deletion_requests: {
|
||||
Row: {
|
||||
requested_at: string
|
||||
token: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
requested_at?: string
|
||||
token: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
requested_at?: string
|
||||
token?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "account_deletion_requests_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "users"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
credit_transactions: {
|
||||
Row: {
|
||||
created_at: string
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import brevo from '@getbrevo/brevo';
|
||||
import {
|
||||
VITE_BREVO_API_KEY,
|
||||
VITE_BREVO_SENDER_EMAIL,
|
||||
VITE_BREVO_SENDER_NAME
|
||||
} from '$env/static/private';
|
||||
|
||||
const apiInstance = new brevo.TransactionalEmailsApi();
|
||||
const apiKey = apiInstance.authentications['apiKey'];
|
||||
if (!VITE_BREVO_API_KEY) {
|
||||
throw new Error('Brevo API key is not configured');
|
||||
}
|
||||
apiKey.apiKey = VITE_BREVO_API_KEY;
|
||||
|
||||
const sendEmail = async (subject: string, htmlContentString: string, toAddress: string) => {
|
||||
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||
|
||||
sendSmtpEmail.subject = subject;
|
||||
sendSmtpEmail.htmlContent = htmlContentString;
|
||||
sendSmtpEmail.sender = { name: VITE_BREVO_SENDER_NAME, email: VITE_BREVO_SENDER_EMAIL };
|
||||
sendSmtpEmail.to = [{ email: toAddress, name: toAddress }];
|
||||
sendSmtpEmail.replyTo = { email: VITE_BREVO_SENDER_EMAIL, name: VITE_BREVO_SENDER_NAME };
|
||||
|
||||
apiInstance.sendTransacEmail(sendSmtpEmail).then(
|
||||
function (data) {
|
||||
console.log('API called successfully. Returned data: ' + JSON.stringify(data));
|
||||
},
|
||||
function (error) {
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { sendEmail };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createLogger, transports, format } from 'winston';
|
||||
import { WinstonTransport as AxiomTransport } from '@axiomhq/winston';
|
||||
import { VITE_AXIOM_DATASET, VITE_AXIOM_TOKEN } from '$env/static/private';
|
||||
|
||||
const axiomTransport = new AxiomTransport({
|
||||
dataset: VITE_AXIOM_DATASET,
|
||||
token: VITE_AXIOM_TOKEN
|
||||
});
|
||||
|
||||
const logger = createLogger({
|
||||
level: 'info',
|
||||
defaultMeta: { service: 'example-site' }, // Change this so you can identify your website if you have multiple logging into Axiom
|
||||
format: format.combine(
|
||||
format.errors({ stack: true }), // Captures error stack traces
|
||||
format.timestamp(),
|
||||
format.json() // Formats logs as JSON for structured logging
|
||||
),
|
||||
transports: [
|
||||
new transports.Console({
|
||||
format: format.combine(
|
||||
format.colorize(),
|
||||
format.printf(({ timestamp, level, message, stack }) => {
|
||||
// Stack traces will be included in error messages automatically
|
||||
return `${timestamp} [${level.toUpperCase()}]: ${message}${stack ? `\n${stack}` : ''}`;
|
||||
})
|
||||
)
|
||||
}),
|
||||
axiomTransport
|
||||
],
|
||||
exceptionHandlers: [axiomTransport], // Logs uncaught exceptions
|
||||
rejectionHandlers: [axiomTransport] // Logs unhandled promise rejections
|
||||
});
|
||||
|
||||
// Uncomment this for testing your initial logging setup
|
||||
// logger.log({
|
||||
// level: 'info',
|
||||
// message: 'Logger successfully setup'
|
||||
// });
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,30 @@
|
||||
type RateLimitStore = {
|
||||
[key: string]: { count: number; lastRequest: number };
|
||||
};
|
||||
|
||||
const rateLimitStore: RateLimitStore = {};
|
||||
const RATE_LIMIT_WINDOW = 60 * 5000; // 5 minutes
|
||||
const MAX_REQUESTS = 5;
|
||||
|
||||
export function isRateLimited(ip: string): boolean {
|
||||
const currentTime = Date.now();
|
||||
const requestInfo = rateLimitStore[ip];
|
||||
|
||||
if (!requestInfo) {
|
||||
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentTime - requestInfo.lastRequest > RATE_LIMIT_WINDOW) {
|
||||
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requestInfo.count >= MAX_REQUESTS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
rateLimitStore[ip].count += 1;
|
||||
rateLimitStore[ip].lastRequest = currentTime;
|
||||
return false;
|
||||
}
|
||||
+193
-36
@@ -4,6 +4,8 @@ import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
|
||||
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
|
||||
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
|
||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||
import logger from '$lib/utils/logger/logger';
|
||||
import { sendEmail } from '../brevo/email';
|
||||
|
||||
const toDateTime = (secs: number) => {
|
||||
const t = new Date(+0); // Unix epoch start.
|
||||
@@ -39,7 +41,7 @@ const upsertProductRecord = async (product: stripe.Product) => {
|
||||
|
||||
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}`);
|
||||
logger.info(`Product inserted/updated: ${product.id}`);
|
||||
};
|
||||
|
||||
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
|
||||
@@ -61,7 +63,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
|
||||
|
||||
if (upsertError?.message.includes('foreign key constraint')) {
|
||||
if (retryCount < maxRetries) {
|
||||
console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
|
||||
logger.info(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await upsertPriceRecord(price, retryCount + 1, maxRetries);
|
||||
} else {
|
||||
@@ -72,7 +74,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
|
||||
} else if (upsertError) {
|
||||
throw new Error(`Price insert/update failed: ${upsertError.message}`);
|
||||
} else {
|
||||
console.log(`Price inserted/updated: ${price.id}`);
|
||||
logger.info(`Price inserted/updated: ${price.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,13 +84,13 @@ const deleteProductRecord = async (product: stripe.Product) => {
|
||||
.delete()
|
||||
.eq('id', product.id);
|
||||
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
|
||||
console.log(`Product deleted: ${product.id}`);
|
||||
logger.info(`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}`);
|
||||
logger.info(`Price deleted: ${price.id}`);
|
||||
};
|
||||
|
||||
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
|
||||
@@ -254,11 +256,18 @@ const manageSubscriptionStatusChange = async (
|
||||
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}]`);
|
||||
try {
|
||||
const { error: upsertError } = await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.upsert([subscriptionData]);
|
||||
if (upsertError) {
|
||||
throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
logger.info(`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.
|
||||
@@ -271,7 +280,7 @@ const manageSubscriptionStatusChange = async (
|
||||
};
|
||||
|
||||
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
|
||||
console.log(
|
||||
logger.info(
|
||||
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
|
||||
);
|
||||
|
||||
@@ -284,40 +293,48 @@ const recordProductPurchase = async (userId: string, productId: string, priceId:
|
||||
throw new Error(`Failed to record product purchase: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
console.log(`Product purchased recorded for user [${userId}] and product [${productId}]`);
|
||||
logger.info(`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);
|
||||
try {
|
||||
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 (purchaseError) throw new Error(purchaseError.message);
|
||||
|
||||
if (purchases && purchases.length > 0) {
|
||||
// User has purchased the product
|
||||
return true;
|
||||
if (purchases && purchases.length > 0) {
|
||||
// User has purchased the product
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
// 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']);
|
||||
try {
|
||||
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 (subError) throw new Error(subError.message);
|
||||
|
||||
if (subscriptions && subscriptions.length > 0) {
|
||||
// User has an active subscription
|
||||
return true;
|
||||
if (subscriptions && subscriptions.length > 0) {
|
||||
// User has an active subscription
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -381,7 +398,7 @@ const updateUserCredits = async (userId: string, creditsChange: number, descript
|
||||
|
||||
await logCreditTransaction(userId, creditsChange, description);
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
`${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]`
|
||||
);
|
||||
};
|
||||
@@ -390,7 +407,7 @@ const addCredits = async (userId: string, creditsToAdd: number, description: str
|
||||
try {
|
||||
await updateUserCredits(userId, creditsToAdd, description);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -398,7 +415,7 @@ const deductCredits = async (userId: string, creditsToDeduct: number, descriptio
|
||||
try {
|
||||
await updateUserCredits(userId, -creditsToDeduct, description);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -480,6 +497,142 @@ const getProductById = async (productId: string) => {
|
||||
return product as Product;
|
||||
};
|
||||
|
||||
const requestAccountDeletion = async (userId: string, userEmail: string, baseUrl: string) => {
|
||||
const token = crypto.randomUUID();
|
||||
|
||||
const { error: insertError } = await supabaseAdmin.from('account_deletion_requests').upsert({
|
||||
user_id: userId,
|
||||
token,
|
||||
requested_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (insertError) throw new Error(`Account deletion request failed: ${insertError.message}`);
|
||||
|
||||
const deletionLink = `${baseUrl}/api/delete-account?token=${token}`;
|
||||
|
||||
// Send deletion email using Brevo
|
||||
try {
|
||||
await sendEmail(
|
||||
'Confirm Account Deletion',
|
||||
`<p>Click <a href="${deletionLink}">here</a> to confirm your account deletion.</p>`,
|
||||
userEmail
|
||||
);
|
||||
console.log('Deletion email sent successfully.');
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send deletion email:', emailError);
|
||||
throw new Error('Failed to send confirmation email.');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async (token: string) => {
|
||||
// Verify the token
|
||||
const { data: deletionRequest, error } = await supabaseAdmin
|
||||
.from('account_deletion_requests')
|
||||
.select('user_id')
|
||||
.eq('token', token)
|
||||
.single();
|
||||
|
||||
if (error || !deletionRequest) {
|
||||
return new Response('Invalid or expired token', { status: 400 });
|
||||
}
|
||||
|
||||
// N.B. The SQL here will either cascade delete or nullify the foreign key constraints depending on the table, to enable anonymisation
|
||||
const { error: deletionError } = await supabaseAdmin.auth.admin.deleteUser(
|
||||
deletionRequest.user_id
|
||||
);
|
||||
if (deletionError) {
|
||||
throw new Error(`Failed to delete user: ${deletionError.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserSubscriptions = async (userId: string) => {
|
||||
try {
|
||||
// Step 1: Retrieve subscription data
|
||||
const { data: subscriptions, error } = await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
logger.error('Error retrieving subscriptions:', error.message);
|
||||
throw new Error('Failed to fetch subscriptions');
|
||||
}
|
||||
|
||||
// Step 2: Map through each subscription and fetch product and price details
|
||||
const subscriptionDetails = await Promise.all(
|
||||
subscriptions.map(async (sub) => {
|
||||
// Fetch product and price details from Stripe
|
||||
const product = await stripeClient.products.retrieve(sub.product_id);
|
||||
const price = await stripeClient.prices.retrieve(sub.price_id);
|
||||
|
||||
const expiryDate = new Date(sub.current_period_end);
|
||||
|
||||
// Format output data
|
||||
return {
|
||||
productName: product.name,
|
||||
amount: (price.unit_amount / 100).toFixed(2),
|
||||
currency: price.currency.toUpperCase(),
|
||||
interval: price.recurring?.interval || 'one-time',
|
||||
expiryDate: expiryDate.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}),
|
||||
status: sub.status
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return subscriptionDetails;
|
||||
} catch (error) {
|
||||
logger.error('An error occurred while retrieving subscription details:', error);
|
||||
throw new Error('Could not retrieve subscription details');
|
||||
}
|
||||
};
|
||||
|
||||
const getUserTransactions = async (userId: string) => {
|
||||
try {
|
||||
// Step 1: Retrieve the customer ID associated with the user
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('stripe_customer_id')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error retrieving user data:', error.message);
|
||||
throw new Error('Failed to fetch user data');
|
||||
}
|
||||
|
||||
const customerId = data?.stripe_customer_id;
|
||||
if (!customerId) {
|
||||
throw new Error('No Stripe customer ID found for this user');
|
||||
}
|
||||
|
||||
// Step 2: Fetch charge transactions from Stripe
|
||||
const charges = await stripeClient.charges.list({ customer: customerId });
|
||||
|
||||
// Step 3: Format transaction data
|
||||
const transactions = charges.data.map((charge) => ({
|
||||
amount: (charge.amount / 100).toFixed(2),
|
||||
currency: charge.currency.toUpperCase(),
|
||||
description: charge.description ?? 'No description provided',
|
||||
status: charge.status,
|
||||
created: new Date(charge.created * 1000).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}),
|
||||
receipt_url: charge.receipt_url
|
||||
}));
|
||||
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
console.error('An error occurred while retrieving transactions:', error);
|
||||
throw new Error('Could not retrieve transactions');
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
upsertProductRecord,
|
||||
upsertPriceRecord,
|
||||
@@ -495,5 +648,9 @@ export {
|
||||
getActiveProductsWithPrices,
|
||||
getProductById,
|
||||
upsertCustomerToSupabase,
|
||||
getStripeCustomerId
|
||||
getStripeCustomerId,
|
||||
getUserSubscriptions,
|
||||
getUserTransactions,
|
||||
requestAccountDeletion,
|
||||
deleteAccount
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user