+ 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','') + .replaceAll('
','') + .split('+ 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: DispatchPlease sign up to a paid plan.
+ ++ 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 → + +
+ ++ 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.
+ ++ 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.
+ )} + ++ 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 → + +
+ ++ 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)}.
+ +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.
+ +Your subscription expired on {formatDate(subscription?.expiryDate)}.
+ +Please create a new subscription to regain access.
+ +