diff --git a/site/.env.example b/site/.env.example index 87be0a5e0..4a731e1a3 100644 --- a/site/.env.example +++ b/site/.env.example @@ -44,3 +44,4 @@ NEXTAUTH_URL="" # Lemon squeezy LEMON_SQUEEZY_API_KEY="" LEMON_SQUEEZY_SIGNING_SECRET="" +LEMON_SQUEEZY_STORE_ID="" diff --git a/site/app/(marketing)/billing/change-plan/page.tsx b/site/app/(marketing)/billing/change-plan/page.tsx new file mode 100644 index 000000000..0d8355b72 --- /dev/null +++ b/site/app/(marketing)/billing/change-plan/page.tsx @@ -0,0 +1,41 @@ +import { getSession } from '@/lib/auth' +import Link from 'next/link' +import { PlansComponent } from '@/components/Manage' +import { getPlans, getSubscription } from '@/lib/data' +import { redirect } from 'next/navigation' + +export const metadata = { + title: 'Change plan' +} + +export default async function Page() { + const session = await getSession() + + const sub = await getSubscription(session?.user?.id) + + if (!sub) { + redirect('/billing') + } + + const plans = await getPlans() + + return ( +
+
+

Change plan

+ + ← Back to billing + + {sub.status == 'on_trial' && ( +
+ You are currently on a free trial. You will not be charged when changing plans during a trial. +
+ )} + + + + +
+
+ ) +} \ No newline at end of file diff --git a/site/app/(marketing)/billing/page.tsx b/site/app/(marketing)/billing/page.tsx new file mode 100644 index 000000000..2b21dcd7f --- /dev/null +++ b/site/app/(marketing)/billing/page.tsx @@ -0,0 +1,28 @@ +import { getSession } from '@/lib/auth' +import { getPlans, getSubscription } from '@/lib/data' +/* Full in-app billing component */ +import { SubscriptionComponent } from '@/components/Subscription' + +export const metadata = { + title: 'Billing' +} + +export default async function Page() { + const session = await getSession() + + const plans = await getPlans() + + const sub = await getSubscription(session?.user?.id) + + return ( +
+
+

Billing

+ + + + +
+
+ ) +} \ No newline at end of file diff --git a/site/app/(marketing)/billing/refresh-plans/page.tsx b/site/app/(marketing)/billing/refresh-plans/page.tsx new file mode 100644 index 000000000..d323d1dc9 --- /dev/null +++ b/site/app/(marketing)/billing/refresh-plans/page.tsx @@ -0,0 +1,107 @@ +import prisma from '@/lib/prisma' +import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js' + +const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string) + +export const dynamic = 'force-dynamic' // Don't cache API results + +async function getPlans() { + + const params = { include: ['product'] as Array<'product' | 'files'>, perPage: 50 } + + let hasNextPage = true; + let page = 1; + + let variants = [] as {}[] + let products = [] as Record + + while (hasNextPage) { + const resp = await ls.getVariants(params); + + variants = variants.concat(resp['data']) + products = products.concat(resp['included']) + + if (resp['meta']['page']['lastPage'] > page) { + page += 1 + params['page'] = page + } else { + hasNextPage = false + } + } + + // Nest products inside variants + const prods = {}; + for (let i = 0; i < products.length; i++) { + prods[products[i]['id']] = products[i]['attributes'] + } + for (let i = 0; i < variants.length; i++) { + variants[i]['product'] = prods[variants[i]['attributes']['product_id']] + } + + // Save locally + let variantId, + variant, + product, + productId + + for (let i = 0; i < variants.length; i++) { + + variant = variants[i] + + if ( !variant['attributes']['is_subscription'] ) { + console.log('Not a subscription') + continue + } + + if ( String(variant['product']['store_id']) !== process.env.LEMON_SQUEEZY_STORE_ID ) { + console.log(`Store ID ${variant['product']['store_id']} does not match (${process.env.LEMON_SQUEEZY_STORE_ID})`) + continue + } + + variantId = parseInt(variant['id']) + product = variant['product'] + productId = parseInt(variant['attributes']['product_id']) + + // Get variant's Price objects + let prices = await ls.getPrices({ variantId: variantId, perPage: 100 }) + // The first object is the latest/current price + let variant_price = prices['data'][0]['attributes']['unit_price'] + variant = variant['attributes'] + + const updateData = { + productId: productId, + name: product['name'], + variantName: variant['name'], + status: variant['status'], + sort: variant['sort'], + description: variant['description'], + price: variant_price, // display price in the app matches current Price object in LS + interval: variant['interval'], + intervalCount: variant['interval_count'], + } + const createData = { ...updateData, variantId} + + try { + await prisma.plan.upsert({ + where: { + variantId: variantId + }, + update: updateData, + create: createData + }) + } catch (error) { + console.log(variant) + console.log(error) + } + } +} + +export default async function Page() { + await getPlans() + + return ( +

+ Done! +

+ ) +} \ No newline at end of file diff --git a/site/app/(marketing)/billing/webhook/route.ts b/site/app/(marketing)/billing/webhook/route.ts new file mode 100644 index 000000000..949ceb8dc --- /dev/null +++ b/site/app/(marketing)/billing/webhook/route.ts @@ -0,0 +1,152 @@ +import prisma from '@/lib/prisma' +import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js' + +const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string) + + +async function processEvent(event) { + + let processingError = '' + + const customData = event.body['meta']['custom_data'] || null + + if (!customData || !customData['user_id']) { + + processingError = 'No user ID, can\'t process' + + } else { + + const obj = event.body['data'] + + if (event.eventName.startsWith('subscription_payment_')) { + // Save subscription invoices; obj is a "Subscription invoice" + + /* Not implemented */ + + } else if (event.eventName.startsWith('subscription_')) { + // Save subscriptions; obj is a "Subscription" + + const data = obj['attributes'] + + // We assume the Plan table is up to date + const plan = await prisma.plan.findUnique({ + where: { + variantId: data['variant_id'] + }, + }) + + if (!plan) { + + processingError = 'Plan not found in DB. Could not process webhook event.' + + } else { + + // Update the subscription + + const lemonSqueezyId = parseInt(obj['id']) + + // Get subscription's Price object + // We save the Price value to the subscription so we can display it in the UI + let priceData = await ls.getPrice({ id: data['first_subscription_item']['price_id'] }) + + const updateData = { + orderId: data['order_id'], + name: data['user_name'], + email: data['user_email'], + status: data['status'], + renewsAt: data['renews_at'], + endsAt: data['ends_at'], + trialEndsAt: data['trial_ends_at'], + planId: plan['id'], + userId: customData['user_id'], + price: priceData['data']['attributes']['unit_price'], + subscriptionItemId: data['first_subscription_item']['id'], + // Save this for usage-based billing reporting; no need to if you use quantity-based billing + isUsageBased: data['first_subscription_item']['is_usage_based'], + } + + const createData = { ...updateData, lemonSqueezyId} + createData.price = plan.price + + try { + // Create/update subscription + await prisma.subscription.upsert({ + where: { + lemonSqueezyId: lemonSqueezyId + }, + update: updateData, + create: createData, + }) + } catch (error) { + processingError = error + console.log(error) + } + + } + + } else if (event.eventName.startsWith('order_')) { + // Save orders; obj is a "Order" + + /* Not implemented */ + + } else if (event.eventName.startsWith('license_')) { + // Save license keys; obj is a "License key" + + /* Not implemented */ + + } + + try { + // Mark event as processed + await prisma.webhookEvent.update({ + where: { + id: event.id + }, + data: { + processed: true, + processingError + } + }) + } catch (error) { + console.log(error) + } + + } +} + + +export async function POST(request: Request) { + + // Make sure request is from Lemon Squeezy + + const crypto = require('crypto') + + const rawBody = await request.text() + + const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET + const hmac = crypto.createHmac('sha256', secret) + const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8') + const signature = Buffer.from(request.headers.get('X-Signature') || '', 'utf8') + + if (!crypto.timingSafeEqual(digest, signature)) { + throw new Error('Invalid signature.') + } + + // Now save the event + + const data = JSON.parse(rawBody) + + const event = await prisma.webhookEvent.create({ + data: { + eventName: data['meta']['event_name'], + body: data + }, + }) + + // Process the event + // This could be done out of the main thread + + processEvent(event) + + return new Response('Done'); +} \ No newline at end of file diff --git a/site/app/api/checkouts/route.ts b/site/app/api/checkouts/route.ts new file mode 100644 index 000000000..61d2cb8a3 --- /dev/null +++ b/site/app/api/checkouts/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js' + +const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string) + + +export async function POST(request: Request) { + const session = await getSession() + + if (!session) { + return NextResponse.json({ error: true, message: 'Not logged in.' }, { status: 401 }) + } + + const res = await request.json() + + if ( !res.variantId ) { + return NextResponse.json({ error: true, message: 'No variant ID was provided.' }, { status: 400 }) + } + + // Customise the checkout experience + // All the options: https://docs.lemonsqueezy.com/api/checkouts#create-a-checkout + const attributes = { + 'checkout_options': { + 'embed': true, + 'media': false, + 'button_color': '#fde68a' + }, + 'checkout_data': { + 'email': session.user?.email, // Displays in the checkout form + 'custom': { + 'user_id': session.user?.id // Sent in the background; visible in webhooks and API calls + } + }, + 'product_options': { + 'enabled_variants': [res.variantId], // Only show the selected variant in the checkout + 'redirect_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`, + 'receipt_link_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`, + 'receipt_button_text': 'Go to your account', + 'receipt_thank_you_note': 'Thank you for signing up to Lemonstand!' + } + } + + try { + const checkout = await ls.createCheckout({ + storeId: Number(process.env.LEMON_SQUEEZY_STORE_ID), + variantId: res.variantId, + attributes + }) + + return NextResponse.json({'error': false, 'url': checkout['data']['attributes']['url']}, {status: 200}) + } catch (e) { + return NextResponse.json({'error': true, 'message': e.message}, {status: 400}) + } +} \ No newline at end of file diff --git a/site/app/api/subscriptions/[id]/route.ts b/site/app/api/subscriptions/[id]/route.ts new file mode 100644 index 000000000..71c934f64 --- /dev/null +++ b/site/app/api/subscriptions/[id]/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from 'next/server' +import { getPlan } from '@/lib/data' +import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js' + +const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string) + +export async function GET(_: Request, { params }: { params: { id: string } }) { + /** + * Used by some buttons to get subscription update billing and customer portal URLs + */ + try { + const subscription = await ls.getSubscription({ id: Number(params.id) }) + return NextResponse.json({ error: false, subscription: { + update_billing_url: subscription['data']['attributes']['urls']['update_payment_method'], + customer_portal_url: subscription['data']['attributes']['urls']['customer_portal'] + } }, { status: 200 }) + } catch (e) { + return NextResponse.json({ error: true, message: e.message }, { status: 400 }) + } +} + +export async function POST(request: Request, { params }: { params: { id: string } }) { + + const res = await request.json() + + let subscription + + if (res.variantId && res.productId) { + + // Update plan + + try { + subscription = await ls.updateSubscription({ + id: Number(params.id), + productId: res.productId, + variantId: res.variantId, + }) + } catch (e) { + return NextResponse.json({ error: true, message: e.message }, { status: 400 }) + } + + } else if (res.action == 'resume') { + + // Resume + + try { + subscription = await ls.resumeSubscription({ id: Number(params.id) }) + } catch (e) { + return NextResponse.json({ error: true, message: e.message }, { status: 400 }) + } + + } else if (res.action == 'cancel') { + + // Cancel + + try { + subscription = await ls.cancelSubscription({ id: Number(params.id) }) + } catch (e) { + return NextResponse.json({ error: true, message: e.message }, { status: 400 }) + } + + } else if (res.action == 'pause') { + + // Pause + + try { + subscription = await ls.pauseSubscription({ id: Number(params.id) }) + } catch (e) { + return NextResponse.json({ error: true, message: e.message }, { status: 400 }) + } + + } else if (res.action == 'unpause') { + + // Unpause + + try { + subscription = await ls.unpauseSubscription({ id: Number(params.id) }) + } catch (e) { + return NextResponse.json({ error: true, message: e.message }, { status: 400 }) + } + + } else { + + // Missing data in request + + return NextResponse.json({ error: true, message: 'Valid data not found.' }, { status: 400 }) + + } + + // Return values needed to refresh state in UI + // DB will be updated in the background with webhooks + + // Get price + let resp = await ls.getPrice({ id: subscription['data']['attributes']['first_subscription_item']['price_id'] }) + let subItemPrice = resp['data']['attributes']['unit_price'] + + // Return a filtered subscription object to the UI + const sub = { + product_id: subscription['data']['attributes']['product_id'], + variant_id: subscription['data']['attributes']['variant_id'], + status: subscription['data']['attributes']['status'], + card_brand: subscription['data']['attributes']['card_brand'], + card_last_four: subscription['data']['attributes']['card_last_four'], + trial_ends_at: subscription['data']['attributes']['trial_ends_at'], + renews_at: subscription['data']['attributes']['renews_at'], + ends_at: subscription['data']['attributes']['ends_at'], + resumes_at: subscription['data']['attributes']['resumes_at'], + plan: {}, + price: subItemPrice, + } + + // Get plan's data + const plan = await getPlan(sub.variant_id) + sub.plan = { + interval: plan?.interval, + name: plan?.variantName + } + + return NextResponse.json({ error: false, subscription: sub }, { status: 200 }) + +} \ No newline at end of file diff --git a/site/components/Manage.tsx b/site/components/Manage.tsx new file mode 100644 index 000000000..a01fdfe4d --- /dev/null +++ b/site/components/Manage.tsx @@ -0,0 +1,292 @@ +'use client' + +import { useState, MouseEvent, Dispatch, SetStateAction } from 'react' +import PlanCards from '@/components/Plan' +import { Plan, Subscription, SubscriptionState } from '@/types' +import clsx from 'clsx' + +export const UpdateBillingLink = ({ subscription, elementType }: + { subscription: SubscriptionState, elementType?: string } +) => { + + const [isMutating, setIsMutating] = useState(false) + + async function openUpdateModal(e: MouseEvent) { + + e.preventDefault() + + setIsMutating(true) + + /* Send request */ + const res = await fetch(`/api/subscriptions/${subscription?.id}`) + const result = await res.json() + if (result.error) { + alert(result.message) + setIsMutating(false) + + } else { + + LemonSqueezy.Url.Open(result.subscription.update_billing_url) + setIsMutating(false) + + } + } + + if (elementType == 'button') { + return ( + + Update your payment method + + ) + } else { + return ( + + Update your payment method + + ) + } +} + +export const CancelLink = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + + const [isMutating, setIsMutating] = useState(false) + + async function handleCancel(e: MouseEvent) { + + e.preventDefault() + + if (confirm(`Please confirm you want to cancel your subscription.`)) { + + setIsMutating(true) + + /* Send request */ + const res = await fetch(`/api/subscriptions/${subscription?.id}`, { + method: 'POST', + body: JSON.stringify({ + action: 'cancel' + }) + }) + const result = await res.json() + if (result.error) { + alert(result.message) + setIsMutating(false) + + } else { + + setSubscription({ + ...subscription, + status: result['subscription']['status'], + expiryDate: result['subscription']['ends_at'], + }) + + alert('Your subscription has been cancelled.') + } + } + } + + return ( + + Cancel + + ) +} + +export const ResumeButton = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + + const [isMutating, setIsMutating] = useState(false) + + const resumeSubscription = async (e: MouseEvent) => { + + e.preventDefault() + + if (confirm(`Please confirm you want to resume your subscription. You will be charged the regular subscription fee.`)) { + + setIsMutating(true) + + /* Send request */ + const res = await fetch(`/api/subscriptions/${subscription?.id}`, { + method: 'POST', + body: JSON.stringify({ + action: 'resume' + }) + }) + const result = await res.json() + if (result.error) { + alert(result.message) + setIsMutating(false) + } else { + + setSubscription({ + ...subscription, + status: result['subscription']['status'], + renewalDate: result['subscription']['renews_at'], + }) + + alert('Your subscription is now active again!.') + } + } + } + + return ( + + Resume your subscription + + ) +} + +export const PauseLink = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + + const [isMutating, setIsMutating] = useState(false) + + async function handlePause(e: MouseEvent) { + + e.preventDefault() + + if (confirm(`Please confirm you want to pause your subscription.`)) { + + setIsMutating(true) + + /* Send request */ + const res = await fetch(`/api/subscriptions/${subscription?.id}`, { + method: 'POST', + body: JSON.stringify({ + action: 'pause' + }) + }) + const result = await res.json() + if (result.error) { + alert(result.message) + setIsMutating(false) + + } else { + + setSubscription({ + ...subscription, + status: result['subscription']['status'], + unpauseDate: result['subscription']['resumes_at'], + }) + + alert('Your subscription has been paused.') + } + } + } + + return ( + + Pause payments + + ) +} + +export const UnpauseButton = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + + const [isMutating, setIsMutating] = useState(false) + + const unpauseSubscription = async (e: MouseEvent) => { + + e.preventDefault() + + if (confirm(`Please confirm you want to unpause your subscription. Your payments will reactivate on their original schedule.`)) { + + setIsMutating(true) + + /* Send request */ + const res = await fetch(`/api/subscriptions/${subscription?.id}`, { + method: 'POST', + body: JSON.stringify({ + action: 'unpause' + }) + }) + const result = await res.json() + if (result.error) { + alert(result.message) + setIsMutating(false) + } else { + + setSubscription({ + ...subscription, + status: result['subscription']['status'], + renewalDate: result['subscription']['renews_at'], + }) + + alert('Your subscription is now active again!') + } + } + } + + return ( + + Unpause your subscription + + ) +} + +export const PlansComponent = ({ plans, sub }: + { plans: Plan[], sub: Subscription } +) => { + + const [subscription, setSubscription] = useState(() => { + if (sub) { + return { + id: sub.lemonSqueezyId, + planName: sub.plan?.variantName, + planInterval: sub.plan?.interval, + productId: sub.plan?.productId, + variantId: sub.plan?.variantId, + status: sub.status, + renewalDate: sub.renewsAt, + trialEndDate: sub.trialEndsAt, + expiryDate: sub.endsAt, + } + } else { + return undefined + } + }) + + return ( + + ) +} \ No newline at end of file diff --git a/site/components/Plan.tsx b/site/components/Plan.tsx new file mode 100644 index 000000000..a77bb0f4b --- /dev/null +++ b/site/components/Plan.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState, useEffect, Dispatch, SetStateAction } from 'react' +import PlanButton from '@/components/PlanButton' +import { isPlanFeatured } from '@/helpers'; +import clsx from 'clsx'; +import Icon from '@/components/Icon'; +import type { Plan, SubscriptionState } from '@/types'; + +function formatPrice(price: number) { + const priceString = price.toString() + const dollars = priceString.substring(0, priceString.length-2) + const cents = priceString.substring(priceString.length-2) + if (cents === '00') return dollars + return `${dollars}.${cents}` +} + +const formatDescription = (description?: string) => { + if (!description) return; + const pricingFeatures = description + .replaceAll('

','') + .replaceAll('

','') + .split('
') + return
    + { + pricingFeatures.map((pricingFeature) => ( +
  • + + {pricingFeature} +
  • + )) + } +
+} + +const IntervalSwitcher = ({ intervalValue, changeInterval }: + { intervalValue: string, changeInterval: Dispatch> } +) => { + return ( +
+ Monthly + + Yearly +
+ ) +} + + +const PlanCard = ({ plan, subscription, intervalValue, setSubscription }: + { plan: Plan, subscription: SubscriptionState, intervalValue: string, setSubscription: Dispatch> } +) => { + return ( +
+ { + isPlanFeatured(plan) && +
+
Popular
+
+ } + +

{plan.variantName}

+ +
+ $ + { formatPrice(plan.price) } +
+
per {plan.interval}
+
+
+ + {formatDescription(plan.description || '')} + + +
+ ) +} + +const PlanCards = ({ plans, subscription, setSubscription }: + { plans: Plan[], subscription?: SubscriptionState, setSubscription: Dispatch> } +) => { + + const [intervalValue, setIntervalValue] = useState('month') + + // Make sure Lemon.js is loaded + useEffect(() => { + window.createLemonSqueezy() + }, []) + + return ( + <> + + +
+ + {plans.map(plan => ( + + ))} + +
+ +

+ Payments are processed securely by Lemon Squeezy. +

+ + ) +} + +export default PlanCards \ No newline at end of file diff --git a/site/components/PlanButton.tsx b/site/components/PlanButton.tsx new file mode 100644 index 000000000..c48b43c98 --- /dev/null +++ b/site/components/PlanButton.tsx @@ -0,0 +1,124 @@ +'use client' + +import { Dispatch, SetStateAction, useState, MouseEvent } from 'react' +import clsx from 'clsx'; +import { isPlanFeatured } from '@/helpers'; +import { Plan, SubscriptionState } from '@/types'; + +const PlanButton = ({ plan, subscription, setSubscription }: + { plan: Plan, subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + + const [isMutating, setIsMutating] = useState(false) + + const createCheckout = async (e: MouseEvent, variantId: number) => { + e.preventDefault() + + if (isMutating) return + + setIsMutating(true) + + // Create a checkout + const res = await fetch('/api/checkouts', { + method: 'POST', + body: JSON.stringify({ + variantId: variantId + }) + }) + const checkout = await res.json() + if (checkout.error) { + alert(checkout.message) + } else { + LemonSqueezy.Url.Open(checkout['url']) + } + + setIsMutating(false) + } + + const changePlan = async (e: MouseEvent, subscription: SubscriptionState, plan: Plan) => { + e.preventDefault() + + if (isMutating) return + + if (confirm(`Please confirm you want to change to the ${plan.variantName} ${plan.interval}ly plan. \ +For upgrades you will be charged a prorated amount.`)) { + + setIsMutating(true) + + // Send request + const res = await fetch(`/api/subscriptions/${subscription?.id}`, { + method: 'POST', + body: JSON.stringify({ + variantId: plan.variantId, + productId: plan.productId + }) + }) + const result = await res.json() + if (result.error) { + alert(result.message) + } else { + setSubscription({ + ...subscription, + productId: result['subscription']['product_id'], + variantId: result['subscription']['variant_id'], + planName: result['subscription']['plan']['name'], + planInterval: result['subscription']['plan']['interval'], + status: result['subscription']['status'], + renewalDate: result['subscription']['renews_at'], + price: result['subscription']['price'] + }) + + alert('Your subscription plan has changed!') + + // Webhooks will update the DB in the background + } + + setIsMutating(false) + + } + } + + return ( + <> + {(!subscription || subscription.status == 'expired') ? ( + // Show a "Sign up" button to customers with no subscription + + + ) : ( + <> + {subscription?.variantId == plan.variantId ? ( +
+ Your current plan +
+ ) : ( + + )} + + )} + + ) +} + +export default PlanButton \ No newline at end of file diff --git a/site/components/Subscription.tsx b/site/components/Subscription.tsx new file mode 100644 index 000000000..3a01fadb3 --- /dev/null +++ b/site/components/Subscription.tsx @@ -0,0 +1,238 @@ +'use client' + +import { useState, useEffect, SetStateAction, Dispatch } from 'react' +import Link from 'next/link' +import PlanCards from '@/components/Plan' +import { + UpdateBillingLink, + CancelLink, + ResumeButton, + PauseLink, + UnpauseButton +} from '@/components/Manage' +import { Plan, Subscription, SubscriptionState } from '@/types' + +export const SubscriptionComponent = ({ sub, plans }: + { sub: Subscription | null, plans: Plan[] } +) => { + + // Make sure Lemon.js is loaded + useEffect(() => { + window.createLemonSqueezy() + }, []) + + const [subscription, setSubscription] = useState(() => { + if (sub) { + return { + id: sub.lemonSqueezyId, + planName: sub.plan?.variantName, + planInterval: sub.plan?.interval, + productId: sub.plan?.productId, + variantId: sub.plan?.variantId, + status: sub.status, + renewalDate: sub.renewsAt, + trialEndDate: sub.trialEndsAt, + expiryDate: sub.endsAt, + unpauseDate: sub.resumesAt, + price: sub.price / 100, + } + } else { + return undefined + } + }) + + if (sub) { + + switch(subscription?.status) { + + case 'active': + return + case 'on_trial': + return + case 'past_due': + return + case 'cancelled': + return + case 'paused': + return + case 'unpaid': + return + case 'expired': + return + } + + } + + return ( + <> +

Please sign up to a paid plan.

+ + + + ) +} + +export const ActiveSubscription = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + return ( + <> +

+ You are currently on the {subscription?.planName} {subscription?.planInterval}ly plan, paying ${subscription?.price}/{subscription?.planInterval}. +

+ +

Your next renewal will be on {formatDate(subscription?.renewalDate)}.

+ +
+ +

+ + Change plan → + +

+ +

+ +

+ +

+ + ) +} + +export const CancelledSubscription = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + return ( + <> +

+ You are currently on the {subscription?.planName} {subscription?.planInterval}ly plan, paying ${subscription?.price}/{subscription?.planInterval}. +

+ +

Your subscription has been cancelled and will end on {formatDate(subscription?.expiryDate)}. After this date you will no longer have access to the app.

+ +

+ + ) +} + +export const PausedSubscription = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + return ( + <> +

+ You are currently on the {subscription?.planName} {subscription?.planInterval}ly plan, paying ${subscription?.price}/{subscription?.planInterval}. +

+ + {subscription?.unpauseDate ? ( +

Your subscription payments are currently paused. Your subscription will automatically resume on {formatDate(subscription?.unpauseDate)}.

+ ) : ( +

Your subscription payments are currently paused.

+ )} + +

+ + ) +} + +export const TrialSubscription = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + return ( + <> +

+ You are currently on a free trial of the {subscription?.planName} {subscription?.planInterval}ly plan, paying ${subscription?.price}/{subscription?.planInterval}. +

+ +

Your trial ends on {formatDate(subscription?.trialEndDate)}. You can cancel your subscription before this date and you won't be charged.

+ +
+ +

+ + Change plan → + +

+ +

+ +

+ + ) +} + +export const PastDueSubscription = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + return ( + <> +
+ Your latest payment failed. We will re-try this payment up to four times, after which your subscription will be cancelled.
+ If you need to update your billing details, you can do so below. +
+ +

+ You are currently on the {subscription?.planName} {subscription?.planInterval}ly plan, paying ${subscription?.price}/{subscription?.planInterval}. +

+ +

We will attempt a payment on {formatDate(subscription?.renewalDate)}.

+ +
+ +

+ +

+ + ) +} + +export const UnpaidSubscription = ({ subscription, setSubscription }: + { subscription: SubscriptionState, setSubscription: Dispatch> } +) => { + /* + Unpaid subscriptions have had four failed recovery payments. + If you have dunning enabled in your store settings, customers will be sent emails trying to reactivate their subscription. + If you don't have dunning enabled the subscription will remain "unpaid". + */ + return ( + <> +

We haven't been able to make a successful payment and your subscription is currently marked as unpaid.

+ +

Please update your billing information to regain access.

+ +

+ +
+ +

+ + + ) +} + +export const ExpiredSubscription = ({ subscription, plans, setSubscription }: + { subscription: SubscriptionState, plans: Plan[], setSubscription: Dispatch> } +) => { + return ( + <> +

Your subscription expired on {formatDate(subscription?.expiryDate)}.

+ +

Please create a new subscription to regain access.

+ +
+ + + + + ) +} + +function formatDate(date?: Date | null) { + if (!date) return '' + return new Date(date).toLocaleString('en-US', { + month: 'short', + day: "2-digit", + year: 'numeric' + }) +} \ No newline at end of file diff --git a/site/helpers/index.ts b/site/helpers/index.ts index 9adf46e7d..f39e877d3 100644 --- a/site/helpers/index.ts +++ b/site/helpers/index.ts @@ -1,3 +1,5 @@ +import { Plan } from "@/types"; + export const groupBy = function (xs, key) { return xs.reduce(function (rv, x) { ;(rv[x[key]] = rv[x[key]] || []).push(x); @@ -72,4 +74,8 @@ export const getNextAuthErrorMessage = (error: string): string => { default: return 'Unable to sign in.'; } -}; \ No newline at end of file +}; + +export const isPlanFeatured = (plan: Plan) => { + return plan.variantName === 'Advanced' +} \ No newline at end of file diff --git a/site/lib/auth.ts b/site/lib/auth.ts index 0f13d7274..aacfe040c 100644 --- a/site/lib/auth.ts +++ b/site/lib/auth.ts @@ -1,9 +1,11 @@ -import prisma from '@/lib/prisma'; -import { PrismaAdapter } from '@next-auth/prisma-adapter'; -import GitHubProvider from 'next-auth/providers/github'; -import GoogleProvider from 'next-auth/providers/google'; -import { NextAuthOptions } from 'next-auth'; -import Auth0Provider from 'next-auth/providers/auth0'; +import prisma from '@/lib/prisma' +import { PrismaAdapter } from '@next-auth/prisma-adapter' +import GitHubProvider from 'next-auth/providers/github' +import GoogleProvider from 'next-auth/providers/google' +import { NextAuthOptions } from 'next-auth' +import Auth0Provider from 'next-auth/providers/auth0' +import { getServerSession } from 'next-auth/next' +import type { ExtendedSession } from '@/types' export const authConfig: NextAuthOptions = { providers: [ @@ -70,6 +72,18 @@ export const authConfig: NextAuthOptions = { session: { strategy: 'jwt', }, + callbacks: { + // Add user ID to session from token + session: async ({ session, token }) => { + return { + ...session, + user: { + ...session.user, + id: token.sub, + }, + } + } + } // callbacks: { // session: ({ session, token }) => { // return { @@ -92,3 +106,7 @@ export const authConfig: NextAuthOptions = { // }, // }, }; + +export function getSession(): Promise { + return getServerSession(authConfig) as Promise +} \ No newline at end of file diff --git a/site/lib/data.ts b/site/lib/data.ts new file mode 100644 index 000000000..3dd650829 --- /dev/null +++ b/site/lib/data.ts @@ -0,0 +1,50 @@ +import prisma from '@/lib/prisma' + +export async function getPlans() { + // Gets all active plans + return await prisma.plan.findMany({ + where: { + NOT: [ + { status: 'draft' }, + { status: 'pending' } + ] + }, + include: { + subscriptions: true + } + }) +} + + +export async function getPlan(variantId: number) { + // Gets single active plan by ID + return await prisma.plan.findFirst({ + where: { + variantId: variantId, + NOT: [ + { status: 'draft' }, + { status: 'pending' } + ] + }, + include: { + subscriptions: true + } + }) +} + + +export async function getSubscription(userId?: string) { + // Gets the most recent subscription + return await prisma.subscription.findFirst({ + where: { + userId: userId + }, + include: { + plan: true, + user: true + }, + orderBy: { + lemonSqueezyId: 'desc' + } + }) +} \ No newline at end of file diff --git a/site/package.json b/site/package.json index d9094c361..0baf6a6f3 100644 --- a/site/package.json +++ b/site/package.json @@ -24,6 +24,7 @@ "dependencies": { "@glidejs/glide": "^3.6.0", "@headlessui/react": "^1.7.16", + "@lemonsqueezy/lemonsqueezy.js": "^1.2.2", "@mdx-js/loader": "^2.3.0", "@mdx-js/react": "2.3.0", "@next-auth/prisma-adapter": "^1.0.7", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 781dac044..b459c042e 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@headlessui/react': specifier: ^1.7.16 version: 1.7.16(react-dom@18.2.0)(react@18.2.0) + '@lemonsqueezy/lemonsqueezy.js': + specifier: ^1.2.2 + version: 1.2.2 '@mdx-js/loader': specifier: ^2.3.0 version: 2.3.0(webpack@5.88.2) @@ -2337,6 +2340,10 @@ packages: tslib: 2.4.1 dev: false + /@lemonsqueezy/lemonsqueezy.js@1.2.2: + resolution: {integrity: sha512-6laXtCHYv89boG1TGM0fZtWrp3B8XWIDIeCWVyj37XO4ECyR1EeVIgMQlPbxixuuf7IL+UOGjkQX60Gi8DJKNQ==} + dev: false + /@mdx-js/esbuild@2.3.0(esbuild@0.17.18): resolution: {integrity: sha512-r/vsqsM0E+U4Wr0DK+0EfmABE/eg+8ITW4DjvYdh3ve/tK2safaqHArNnaqbOk1DjYGrhxtoXoGaM3BY8fGBTA==} peerDependencies: diff --git a/site/prisma/migrations/20231112220356_init/migration.sql b/site/prisma/migrations/20231112220356_init/migration.sql deleted file mode 100644 index ed85f140f..000000000 --- a/site/prisma/migrations/20231112220356_init/migration.sql +++ /dev/null @@ -1,96 +0,0 @@ --- CreateEnum -CREATE TYPE "SubscriptionStatus" AS ENUM ('on_trial', 'active', 'paused', 'past_due', 'unpaid', 'cancelled', 'expired'); - --- CreateEnum -CREATE TYPE "CardBrand" AS ENUM ('visa', 'mastercard', 'american_express', 'discover', 'jcb', 'diners_club'); - --- CreateTable -CREATE TABLE "accounts" ( - "id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "provider_account_id" TEXT NOT NULL, - "refresh_token" TEXT, - "access_token" TEXT, - "expires_at" INTEGER, - "token_type" TEXT, - "scope" TEXT, - "id_token" TEXT, - "session_state" TEXT, - "refresh_token_expires_in" INTEGER, - - CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "sessions" ( - "id" TEXT NOT NULL, - "session_token" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "users" ( - "id" TEXT NOT NULL, - "name" TEXT, - "email" TEXT, - "email_verified" TIMESTAMP(3), - "image" TEXT, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "verificationtokens" ( - "identifier" TEXT NOT NULL, - "token" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL -); - --- CreateTable -CREATE TABLE "Subscription" ( - "id" SERIAL NOT NULL, - "store_id" INTEGER, - "customer_id" INTEGER, - "order_id" INTEGER, - "order_item_id" INTEGER, - "product_id" INTEGER, - "variant_id" INTEGER, - "product_name" TEXT, - "variant_name" TEXT, - "user_name" TEXT, - "user_email" TEXT, - "status" "SubscriptionStatus" NOT NULL, - "status_formatted" TEXT, - "card_brand" "CardBrand" NOT NULL, - "card_last_four" TEXT NOT NULL, - "pause" JSONB, - "cancelled" BOOLEAN, - - CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); - --- CreateIndex -CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token"); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token"); - --- AddForeignKey -ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/site/prisma/migrations/20231206000400_init/migration.sql b/site/prisma/migrations/20231206000400_init/migration.sql new file mode 100644 index 000000000..b15995220 --- /dev/null +++ b/site/prisma/migrations/20231206000400_init/migration.sql @@ -0,0 +1,138 @@ +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + "refresh_token_expires_in" INTEGER, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "session_token" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "email_verified" TIMESTAMP(3), + "image" TEXT, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verificationtokens" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" SERIAL NOT NULL, + "lemon_squeezy_id" INTEGER NOT NULL, + "order_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "status" TEXT NOT NULL, + "renews_at" TIMESTAMP(3), + "ends_at" TIMESTAMP(3), + "trial_ends_at" TIMESTAMP(3), + "resumes_at" TIMESTAMP(3), + "price" INTEGER NOT NULL, + "plan_id" INTEGER NOT NULL, + "user_id" TEXT NOT NULL, + "is_usage_based" BOOLEAN NOT NULL DEFAULT false, + "subscription_item_id" INTEGER, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Plan" ( + "id" SERIAL NOT NULL, + "product_id" INTEGER NOT NULL, + "variant_id" INTEGER NOT NULL, + "name" TEXT, + "description" TEXT, + "variant_name" TEXT NOT NULL, + "sort" INTEGER NOT NULL, + "status" TEXT NOT NULL, + "price" INTEGER NOT NULL, + "interval" TEXT NOT NULL, + "interval_count" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "Plan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WebhookEvent" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "event_name" TEXT NOT NULL, + "processed" BOOLEAN NOT NULL DEFAULT false, + "body" JSONB NOT NULL, + "processing_error" TEXT, + + CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_lemon_squeezy_id_key" ON "Subscription"("lemon_squeezy_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_order_id_key" ON "Subscription"("order_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_subscription_item_id_key" ON "Subscription"("subscription_item_id"); + +-- CreateIndex +CREATE INDEX "Subscription_plan_id_lemon_squeezy_id_idx" ON "Subscription"("plan_id", "lemon_squeezy_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Plan_variant_id_key" ON "Plan"("variant_id"); + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/site/prisma/schema.prisma b/site/prisma/schema.prisma index d65e616b8..052080f25 100644 --- a/site/prisma/schema.prisma +++ b/site/prisma/schema.prisma @@ -3,9 +3,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("POSTGRES_PRISMA_URL") - directUrl = env("POSTGRES_URL_NON_POOLING") + provider = "postgresql" + url = env("POSTGRES_PRISMA_URL") + directUrl = env("POSTGRES_URL_NON_POOLING") } model Account { @@ -39,13 +39,14 @@ model Session { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique - emailVerified DateTime? @map("email_verified") + email String? @unique + emailVerified DateTime? @map("email_verified") image String? accounts Account[] sessions Session[] + subscription Subscription[] @@map("users") } @@ -59,41 +60,48 @@ model VerificationToken { @@map("verificationtokens") } -enum SubscriptionStatus { - on_trial - active - paused - past_due - unpaid - cancelled - expired -} - -enum CardBrand { - visa - mastercard - american_express - discover - jcb - diners_club -} - model Subscription { - id Int @id @default(autoincrement()) - store_id Int? - customer_id Int? - order_id Int? - order_item_id Int? - product_id Int? - variant_id Int? - product_name String? - variant_name String? - user_name String? - user_email String? - status SubscriptionStatus - status_formatted String? - card_brand CardBrand - card_last_four String - pause Json? - cancelled Boolean? + id Int @id @default(autoincrement()) + lemonSqueezyId Int @unique @map("lemon_squeezy_id") + orderId Int @unique @map("order_id") + name String + email String + status String + renewsAt DateTime? @map("renews_at") + endsAt DateTime? @map("ends_at") + trialEndsAt DateTime? @map("trial_ends_at") + resumesAt DateTime? @map("resumes_at") + price Int + plan Plan @relation(fields: [planId], references: [id]) + planId Int @map("plan_id") + user User @relation(fields: [userId], references: [id]) + userId String @map("user_id") + isUsageBased Boolean @default(false) @map("is_usage_based") + subscriptionItemId Int? @unique @map("subscription_item_id") + + @@index([planId, lemonSqueezyId]) +} + +model Plan { + id Int @id @default(autoincrement()) + productId Int @map("product_id") + variantId Int @unique @map("variant_id") + name String? // Need to get from Product + description String? + variantName String @map("variant_name") + sort Int + status String + price Int + interval String + intervalCount Int @default(1) @map("interval_count") + subscriptions Subscription[] +} + +model WebhookEvent { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + eventName String @map("event_name") + processed Boolean @default(false) + body Json + processingError String? @map("processing_error") } diff --git a/site/styles/_forms.scss b/site/styles/_forms.scss index 1e55668de..44031db42 100644 --- a/site/styles/_forms.scss +++ b/site/styles/_forms.scss @@ -353,3 +353,57 @@ $form-range-thumb-transition: background-color .3s ease-in-out, bor border-color: $color-primary; } } + +/* Switch */ +.form-switch { + position: relative; + display: inline-block; + height: 1.5rem; + width: 2.75rem; +} + +.form-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: .4s; + transition: .4s; + border-radius: 34px; +} + +.slider:before { + position: absolute; + content: ""; + height: 1rem; + width: 1rem; + left: 4px; + bottom: 4px; + background-color: white; + -webkit-transition: .4s; + transition: .4s; + border-radius: 50%; +} + +input:checked + .slider { + background-color: $color-primary; +} + +input:focus + .slider { + box-shadow: 0 0 1px $color-primary; +} + +input:checked + .slider:before { + -webkit-transform: translateX(1rem); + -ms-transform: translateX(1rem); + transform: translateX(1rem); +} diff --git a/site/types/index.ts b/site/types/index.ts index b2d30e010..5d1b79206 100644 --- a/site/types/index.ts +++ b/site/types/index.ts @@ -1,3 +1,6 @@ +import { type Session } from 'next-auth'; +import { Prisma } from '@prisma/client' + export type IconType = { name: string tags: string[] @@ -18,3 +21,37 @@ export type DocsItem = { } export type DocsConfigType = DocsItem[] + +export type ExtendedSession = Session & { user?: { id?: string} } + +const userWithRelations = Prisma.validator()({ + include: { accounts: true, sessions: true, subscription: true }, +}) + +export type User = Prisma.UserGetPayload + +const planWithRelations = Prisma.validator()({ + include: { subscriptions: true }, +}) + +export type Plan = Prisma.PlanGetPayload + +const subscriptionWithRelations = Prisma.validator()({ + include: { plan: true, user: true }, +}) + +export type Subscription = Prisma.SubscriptionGetPayload + +export type SubscriptionState = { + id?: number, + planName?: string, + planInterval?: string, + productId?: number, + variantId?: number, + status?: string, + renewalDate?: Date | null, + trialEndDate?: Date | null, + expiryDate?: Date | null, + unpauseDate?: Date | null, + price?: number, +} | undefined \ No newline at end of file