From 1d6015ae4e87524fe0d71da92ba951c6740b397f Mon Sep 17 00:00:00 2001 From: d-ivashchuk Date: Mon, 1 Apr 2024 11:33:29 +0200 Subject: [PATCH] working lemon-squeezy billing --- package.json | 1 + pnpm-lock.yaml | 77 +++----- .../migration.sql | 39 ++++ .../migration.sql | 12 ++ .../migration.sql | 10 + .../migration.sql | 4 + .../migration.sql | 8 + prisma/schema.prisma | 48 ++++- src/app/(lemon-squeezy)/ls-setup/page.tsx | 84 +++++++++ .../subscriptions/page.tsx | 21 +-- src/app/(user-scope)/billing/page.tsx | 109 +++++++++++ src/app/api/lemon-squeezy/webhook/route.tsx | 127 +++++++++++++ src/app/api/lemon-squeezy/webhook/utils.ts | 30 +++ src/env.mjs | 6 + src/server/api/root.ts | 2 +- src/server/api/routers/index.ts | 89 --------- src/server/api/routers/lemon-squeezy.ts | 178 ++++++++++++++++++ src/types/lemonsqueezy/index.ts | 61 ++++++ 18 files changed, 751 insertions(+), 155 deletions(-) create mode 100644 prisma/migrations/20240331180158_add_lemon_squeezy_related_variables/migration.sql create mode 100644 prisma/migrations/20240401075751_add_new_field_to_sub/migration.sql create mode 100644 prisma/migrations/20240401080450_remove_some_fields/migration.sql create mode 100644 prisma/migrations/20240401080712_add_url_fields_to_ls_sub/migration.sql create mode 100644 prisma/migrations/20240401085643_add_variant_id/migration.sql create mode 100644 src/app/(lemon-squeezy)/ls-setup/page.tsx rename src/app/{(subscriptions) => (lemon-squeezy)}/subscriptions/page.tsx (94%) create mode 100644 src/app/(user-scope)/billing/page.tsx create mode 100644 src/app/api/lemon-squeezy/webhook/route.tsx create mode 100644 src/app/api/lemon-squeezy/webhook/utils.ts delete mode 100644 src/server/api/routers/index.ts create mode 100644 src/server/api/routers/lemon-squeezy.ts create mode 100644 src/types/lemonsqueezy/index.ts diff --git a/package.json b/package.json index d6582c1..eff8b6f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@trpc/server": "next", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", "loops": "^1.0.0", "lost-pixel": "^3.16.0", "lucide-react": "^0.363.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d70e8b3..cd8e57d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,25 +55,25 @@ dependencies: version: 2.3.18 '@trpc/client': specifier: next - version: 11.0.0-next.327(@trpc/server@11.0.0-next.327) + version: 11.0.0-rc.329(@trpc/server@11.0.0-rc.329) '@trpc/next': specifier: next - version: 11.0.0-next.327(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-next.327)(@trpc/react-query@11.0.0-next.327)(@trpc/server@11.0.0-next.327)(next@14.1.4)(react-dom@18.2.0)(react@18.2.0) + version: 11.0.0-rc.329(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-rc.329)(@trpc/react-query@11.0.0-rc.329)(@trpc/server@11.0.0-rc.329)(next@14.1.4)(react-dom@18.2.0)(react@18.2.0) '@trpc/react-query': specifier: next - version: 11.0.0-next.327(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-next.327)(@trpc/server@11.0.0-next.327)(react-dom@18.2.0)(react@18.2.0) + version: 11.0.0-rc.329(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-rc.329)(@trpc/server@11.0.0-rc.329)(react-dom@18.2.0)(react@18.2.0) '@trpc/server': specifier: next - version: 11.0.0-next.327 + version: 11.0.0-rc.329 class-variance-authority: specifier: ^0.7.0 version: 0.7.0 clsx: specifier: ^2.1.0 version: 2.1.0 - lemonsqueezy.ts: - specifier: ^0.1.7 - version: 0.1.7 + date-fns: + specifier: ^3.6.0 + version: 3.6.0 loops: specifier: ^1.0.0 version: 1.0.0 @@ -2063,11 +2063,6 @@ packages: resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==} dev: true - /@fastify/busboy@2.1.1: - resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} - engines: {node: '>=14'} - dev: false - /@floating-ui/core@1.6.0: resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} dependencies: @@ -4278,21 +4273,21 @@ packages: - utf-8-validate dev: false - /@trpc/client@11.0.0-next.327(@trpc/server@11.0.0-next.327): - resolution: {integrity: sha512-5Vhem/cavPizA0+CdGDWfJR0XSdljjf927BpKyRt6NfdQP539tP8s0wZsGq/+s9S79hucsDQkh0MoP0Ki3UO4g==} + /@trpc/client@11.0.0-rc.329(@trpc/server@11.0.0-rc.329): + resolution: {integrity: sha512-3evV+4Y2jgw3s4Q2PeMjrDUUrR9lxwNbDkgFRMyKgpO+7syh226gFzuGFd2RQKDeUo7fMTLPvuF/wWXHo4J2CA==} peerDependencies: - '@trpc/server': 11.0.0-next.327+1c757e8d3 + '@trpc/server': 11.0.0-rc.329+fdcb4a7d8 dependencies: - '@trpc/server': 11.0.0-next.327 + '@trpc/server': 11.0.0-rc.329 dev: false - /@trpc/next@11.0.0-next.327(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-next.327)(@trpc/react-query@11.0.0-next.327)(@trpc/server@11.0.0-next.327)(next@14.1.4)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Ks/HRhBiAu46qn2YZxq/t7sllJyO2bktoGZT2g3g28ESK7WuGznbv/hIZYq0TlbPkP/AEQMjP7w9X86E5aG5lA==} + /@trpc/next@11.0.0-rc.329(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-rc.329)(@trpc/react-query@11.0.0-rc.329)(@trpc/server@11.0.0-rc.329)(next@14.1.4)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-sEKPwPsZseVZUrkv7WHTVSbv1JJ+fAa7T0IJ8plOnzWdS78+nmU9Y41Ub547ZK7In4sVboh9BvbOssVIptVltg==} peerDependencies: '@tanstack/react-query': ^5.25.0 - '@trpc/client': 11.0.0-next.327+1c757e8d3 - '@trpc/react-query': 11.0.0-next.327+1c757e8d3 - '@trpc/server': 11.0.0-next.327+1c757e8d3 + '@trpc/client': 11.0.0-rc.329+fdcb4a7d8 + '@trpc/react-query': 11.0.0-rc.329+fdcb4a7d8 + '@trpc/server': 11.0.0-rc.329+fdcb4a7d8 next: '*' react: '>=16.8.0' react-dom: '>=16.8.0' @@ -4303,32 +4298,32 @@ packages: optional: true dependencies: '@tanstack/react-query': 5.28.6(react@18.2.0) - '@trpc/client': 11.0.0-next.327(@trpc/server@11.0.0-next.327) - '@trpc/react-query': 11.0.0-next.327(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-next.327)(@trpc/server@11.0.0-next.327)(react-dom@18.2.0)(react@18.2.0) - '@trpc/server': 11.0.0-next.327 + '@trpc/client': 11.0.0-rc.329(@trpc/server@11.0.0-rc.329) + '@trpc/react-query': 11.0.0-rc.329(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-rc.329)(@trpc/server@11.0.0-rc.329)(react-dom@18.2.0)(react@18.2.0) + '@trpc/server': 11.0.0-rc.329 next: 14.1.4(@babel/core@7.24.3)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false - /@trpc/react-query@11.0.0-next.327(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-next.327)(@trpc/server@11.0.0-next.327)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-B077KFeI2APLVTsXdIMbArb8j7ZwgIE6vlvEKiM0q2cftckgKV0WclUYboZ9Ldd/AFJ8k8Lz92Ot3CroSUg5vA==} + /@trpc/react-query@11.0.0-rc.329(@tanstack/react-query@5.28.6)(@trpc/client@11.0.0-rc.329)(@trpc/server@11.0.0-rc.329)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-JVm0nXiuu9iqxwPtwRvgOmYJx2oWub7+t/7a1KLAIV1E/7uMOhCIoIp1/HzXJ83jILRyJ03igA+xeWD4H1dAmQ==} peerDependencies: '@tanstack/react-query': ^5.25.0 - '@trpc/client': 11.0.0-next.327+1c757e8d3 - '@trpc/server': 11.0.0-next.327+1c757e8d3 + '@trpc/client': 11.0.0-rc.329+fdcb4a7d8 + '@trpc/server': 11.0.0-rc.329+fdcb4a7d8 react: '>=18.2.0' react-dom: '>=18.2.0' dependencies: '@tanstack/react-query': 5.28.6(react@18.2.0) - '@trpc/client': 11.0.0-next.327(@trpc/server@11.0.0-next.327) - '@trpc/server': 11.0.0-next.327 + '@trpc/client': 11.0.0-rc.329(@trpc/server@11.0.0-rc.329) + '@trpc/server': 11.0.0-rc.329 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false - /@trpc/server@11.0.0-next.327: - resolution: {integrity: sha512-Lw4knzW1z0RpiGjur5gU/d67pl1NEkpIokTefuPqb63FlWCfwfHG3FHfvOaNoQ1lt3xMlw/A+pAMeU/q8YWp4Q==} + /@trpc/server@11.0.0-rc.329: + resolution: {integrity: sha512-jiXh4vLRmtwqnuQstuVEAkpOIPe6HAuUxSjcsetT7zWcH5YnSy7ioxRjLmkTcA7RxmN5q4uoveFmMayLWns3NQ==} dev: false /@tsconfig/node10@1.0.10: @@ -6279,6 +6274,10 @@ packages: is-data-view: 1.0.1 dev: true + /date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + dev: false + /debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -8691,13 +8690,6 @@ packages: dotenv-expand: 10.0.0 dev: true - /lemonsqueezy.ts@0.1.7: - resolution: {integrity: sha512-7COfsFaPVsz4ik6+b5hVI/PMyu6FE+PmrbNvk1G89P826XVRRnidVhDAes+Ed42RLyigc8bBHFbuUZSglPTO0g==} - engines: {node: '>=16.*'} - dependencies: - undici: 5.28.3 - dev: false - /leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -11886,13 +11878,6 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - /undici@5.28.3: - resolution: {integrity: sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==} - engines: {node: '>=14.0'} - dependencies: - '@fastify/busboy': 2.1.1 - dev: false - /unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} diff --git a/prisma/migrations/20240331180158_add_lemon_squeezy_related_variables/migration.sql b/prisma/migrations/20240331180158_add_lemon_squeezy_related_variables/migration.sql new file mode 100644 index 0000000..43760c4 --- /dev/null +++ b/prisma/migrations/20240331180158_add_lemon_squeezy_related_variables/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "LemonSqueezyWebhookEvent" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "eventName" TEXT NOT NULL, + "processed" BOOLEAN NOT NULL DEFAULT false, + "body" JSONB NOT NULL, + "processingError" TEXT, + + CONSTRAINT "LemonSqueezyWebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LemonSqueezySubscription" ( + "id" SERIAL NOT NULL, + "lemonSqueezyId" TEXT NOT NULL, + "orderId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "status" TEXT NOT NULL, + "statusFormatted" TEXT NOT NULL, + "renewsAt" TIMESTAMP(3), + "endsAt" TIMESTAMP(3), + "trialEndsAt" TIMESTAMP(3), + "price" TEXT NOT NULL, + "isUsageBased" BOOLEAN NOT NULL DEFAULT false, + "isPaused" BOOLEAN NOT NULL DEFAULT false, + "subscriptionItemId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "planId" INTEGER NOT NULL, + + CONSTRAINT "LemonSqueezySubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "LemonSqueezySubscription_lemonSqueezyId_key" ON "LemonSqueezySubscription"("lemonSqueezyId"); + +-- AddForeignKey +ALTER TABLE "LemonSqueezySubscription" ADD CONSTRAINT "LemonSqueezySubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20240401075751_add_new_field_to_sub/migration.sql b/prisma/migrations/20240401075751_add_new_field_to_sub/migration.sql new file mode 100644 index 0000000..ef07c13 --- /dev/null +++ b/prisma/migrations/20240401075751_add_new_field_to_sub/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `planId` on the `LemonSqueezySubscription` table. All the data in the column will be lost. + - You are about to drop the column `statusFormatted` on the `LemonSqueezySubscription` table. All the data in the column will be lost. + - Added the required column `customerId` to the `LemonSqueezySubscription` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "LemonSqueezySubscription" DROP COLUMN "planId", +DROP COLUMN "statusFormatted", +ADD COLUMN "customerId" TEXT NOT NULL; diff --git a/prisma/migrations/20240401080450_remove_some_fields/migration.sql b/prisma/migrations/20240401080450_remove_some_fields/migration.sql new file mode 100644 index 0000000..0892d64 --- /dev/null +++ b/prisma/migrations/20240401080450_remove_some_fields/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `price` on the `LemonSqueezySubscription` table. All the data in the column will be lost. + - You are about to drop the column `subscriptionItemId` on the `LemonSqueezySubscription` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "LemonSqueezySubscription" DROP COLUMN "price", +DROP COLUMN "subscriptionItemId"; diff --git a/prisma/migrations/20240401080712_add_url_fields_to_ls_sub/migration.sql b/prisma/migrations/20240401080712_add_url_fields_to_ls_sub/migration.sql new file mode 100644 index 0000000..736ead0 --- /dev/null +++ b/prisma/migrations/20240401080712_add_url_fields_to_ls_sub/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "LemonSqueezySubscription" ADD COLUMN "customerPortalUpdateSubscriptionUrl" TEXT, +ADD COLUMN "customerPortalUrl" TEXT, +ADD COLUMN "updatePaymentMethodUrl" TEXT; diff --git a/prisma/migrations/20240401085643_add_variant_id/migration.sql b/prisma/migrations/20240401085643_add_variant_id/migration.sql new file mode 100644 index 0000000..12c670f --- /dev/null +++ b/prisma/migrations/20240401085643_add_variant_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `variantId` to the `LemonSqueezySubscription` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "LemonSqueezySubscription" ADD COLUMN "variantId" TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 67a6cdd..5774772 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,14 +48,46 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - role Role @default(USER) - accounts Account[] - sessions Session[] + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + role Role @default(USER) + accounts Account[] + sessions Session[] + LemonSqueezySubscription LemonSqueezySubscription[] +} + +model LemonSqueezyWebhookEvent { + id Int @id @default(autoincrement()) + eventName String + processed Boolean @default(false) + body Json + createdAt DateTime @default(now()) + processingError String? +} + +model LemonSqueezySubscription { + id Int @id @default(autoincrement()) + lemonSqueezyId String @unique + orderId Int + name String + email String + status String + renewsAt DateTime? + endsAt DateTime? + trialEndsAt DateTime? + isUsageBased Boolean @default(false) + isPaused Boolean @default(false) + customerId String + variantId String + customerPortalUrl String? + updatePaymentMethodUrl String? + customerPortalUpdateSubscriptionUrl String? + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model VerificationToken { diff --git a/src/app/(lemon-squeezy)/ls-setup/page.tsx b/src/app/(lemon-squeezy)/ls-setup/page.tsx new file mode 100644 index 0000000..6770371 --- /dev/null +++ b/src/app/(lemon-squeezy)/ls-setup/page.tsx @@ -0,0 +1,84 @@ +"use client"; +import { LinkIcon, Loader2 } from "lucide-react"; +import React from "react"; +import { Button } from "~/components/ui/button"; +import { Skeleton } from "~/components/ui/skeleton"; +import { Badge } from "~/components/ui/badge"; +import { api } from "~/trpc/react"; +import Link from "next/link"; + +const Page = () => { + const utils = api.useUtils(); + const lemonSqueezyWebhookQuery = api.ls.getWebhook.useQuery(); + const createLemonSqueezyWebhookMutation = api.ls.createLsWebhook.useMutation({ + onSuccess: () => utils.ls.getWebhook.invalidate(), + }); + + return ( +
+

Lemon Squeezy setup

+

+ For local development run the following command in your terminal:{" "} +
+ npx localtunnel --port 3000 --subdomain your-domain and + copy resulting URL to LEMON_SQUEEZY_WEBHOOK_URL in your .env + file. It shall look similar to this:
+ https://cascade.loca.lt/api/lemon-squeezy/webhook +

+

+ For production webhook use your hosted application url e.g.:
+ https://cascade.stackonfire.com/api/lemon-squeezy/webhook +

+ {lemonSqueezyWebhookQuery.isLoading && ( + + )} + {lemonSqueezyWebhookQuery.data && ( +
+
+

Webhook URL

+ {lemonSqueezyWebhookQuery.data.attributes.url && ( + + {lemonSqueezyWebhookQuery.data.attributes.test_mode + ? "Test Mode" + : "Production"} + + )} +
+
+
{" "} +

{lemonSqueezyWebhookQuery.data.attributes.url}

+ + + +
+
+ {lemonSqueezyWebhookQuery.data.attributes.events.map((event) => ( + + {event} + + ))} +
+
+ )} + +
+ ); +}; + +export default Page; diff --git a/src/app/(subscriptions)/subscriptions/page.tsx b/src/app/(lemon-squeezy)/subscriptions/page.tsx similarity index 94% rename from src/app/(subscriptions)/subscriptions/page.tsx rename to src/app/(lemon-squeezy)/subscriptions/page.tsx index d4ab75d..dea3cd6 100644 --- a/src/app/(subscriptions)/subscriptions/page.tsx +++ b/src/app/(lemon-squeezy)/subscriptions/page.tsx @@ -6,6 +6,16 @@ import { Button } from "~/components/ui/button"; import { Skeleton } from "~/components/ui/skeleton"; import { api } from "~/trpc/react"; +//lemosqueezy params +const embed = false; +export const currency = "€"; +const intervalLabels = { + day: "day", + week: "wk", + month: "mo", + year: "yr", +}; + const Subscriptions = () => { const router = useRouter(); @@ -25,16 +35,6 @@ const Subscriptions = () => { const createCheckoutForVariantMutation = api.ls.createCheckoutForVariant.useMutation(); - //lemosqueezy params - const embed = true; - const currency = "€"; - const intervalLabels = { - day: "day", - week: "wk", - month: "mo", - year: "yr", - }; - return (
{productByIdQuery.isLoading && ( @@ -72,7 +72,6 @@ const Subscriptions = () => { if (storeId) { createCheckoutForVariantMutation.mutate( { - storeId, variantId: variant.id, embed, }, diff --git a/src/app/(user-scope)/billing/page.tsx b/src/app/(user-scope)/billing/page.tsx new file mode 100644 index 0000000..5df1e26 --- /dev/null +++ b/src/app/(user-scope)/billing/page.tsx @@ -0,0 +1,109 @@ +"use client"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "~/components/ui/dropdown-menu"; +import Link from "next/link"; +import React from "react"; +import { Card } from "~/components/ui/card"; +import { Skeleton } from "~/components/ui/skeleton"; +import { api } from "~/trpc/react"; +import { Button } from "~/components/ui/button"; +import { AppWindow, Banknote, CreditCard } from "lucide-react"; +import { format } from "date-fns"; +import { currency } from "~/app/(lemon-squeezy)/subscriptions/page"; + +const Billing = () => { + const userSubscriptionQuery = api.ls.getSubscriptionByUserId.useQuery(); + const subscription = userSubscriptionQuery.data?.subscription; + const variant = userSubscriptionQuery.data?.variant; + + return ( +
+

Billing

+ + {userSubscriptionQuery.isLoading && ( + + )} + {userSubscriptionQuery.data ? ( + +
+

Subscription

+ + + + + + {subscription?.customerPortalUrl && ( + + +
+ {" "} + Customer portal +
+ +
+ )} + + {subscription?.updatePaymentMethodUrl && ( + +
+ {" "} + Update payment method +
+ + )} +
+ + {subscription?.customerPortalUpdateSubscriptionUrl && ( + + Update subscription + + )} + +
+
+
+
+ {subscription?.name} | {variant?.data?.data.attributes.name} +
+ {variant?.data?.data.attributes.price && ( +

+ {currency} + {(variant?.data?.data.attributes.price / 100).toFixed(2)} +

+ )} + {subscription?.renewsAt && subscription.status !== "cancelled" && ( +

Renews at: {format(new Date(subscription?.renewsAt), "PP")}

+ )} + {subscription?.endsAt && ( +

Ends at: {format(new Date(subscription?.endsAt), "PP")}

+ )} +

Status: {subscription?.status}

+
+ ) : ( +

+ No subscription found.{" "} + + Subscribe here + {" "} +

+ )} +
+ ); +}; + +export default Billing; diff --git a/src/app/api/lemon-squeezy/webhook/route.tsx b/src/app/api/lemon-squeezy/webhook/route.tsx new file mode 100644 index 0000000..d80dfda --- /dev/null +++ b/src/app/api/lemon-squeezy/webhook/route.tsx @@ -0,0 +1,127 @@ +import crypto from "node:crypto"; +import { env } from "~/env.mjs"; + +import { webhookHasMeta } from "./utils"; +import { db } from "../../../../server/db"; +import { + type LemonsqueezySubscriptionAttributes, + type LemonsqueezyWebhookPayload, +} from "~/types/lemonsqueezy"; + +export async function POST(request: Request) { + if (!env.LEMON_SQUEEZY_WEBHOOK_SECRET) { + return new Response("Lemon Squeezy Webhook Secret not set in .env", { + status: 500, + }); + } + + // First, make sure the request is from Lemon Squeezy. + const rawBody = await request.text(); + const secret = env.LEMON_SQUEEZY_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)) { + return new Response("Invalid signature", { status: 400 }); + } + + const data = JSON.parse(rawBody) as LemonsqueezyWebhookPayload; + + // Type guard to check if the object has a 'meta' property. + if (webhookHasMeta(data)) { + const createdWebhook = await db.lemonSqueezyWebhookEvent.create({ + data: { + eventName: data.meta.event_name, + processed: false, + body: JSON.stringify(data), + }, + }); + const lemonSqueezySubscriptionId = data.data.id; + const subscriptionData = data.data + .attributes as LemonsqueezySubscriptionAttributes; + const userIdInDatabase = data.meta.custom_data.user_id_in_database; + const event = data.meta.event_name; + + const existingSubscription = await db.lemonSqueezySubscription.findFirst({ + where: { lemonSqueezyId: lemonSqueezySubscriptionId }, + }); + + switch (event) { + case "subscription_created": + case "subscription_updated": + await db.lemonSqueezySubscription.upsert({ + where: { lemonSqueezyId: lemonSqueezySubscriptionId }, + update: { + status: subscriptionData.status, + renewsAt: subscriptionData.renews_at + ? new Date(subscriptionData.renews_at) + : null, + endsAt: subscriptionData.ends_at + ? new Date(subscriptionData.ends_at) + : null, + trialEndsAt: subscriptionData.trial_ends_at + ? new Date(subscriptionData.trial_ends_at) + : null, + userId: userIdInDatabase, + customerId: String(subscriptionData.customer_id), + variantId: String(subscriptionData.variant_id), + }, + create: { + lemonSqueezyId: lemonSqueezySubscriptionId, + customerId: String(subscriptionData.customer_id), + orderId: subscriptionData.order_id, + name: subscriptionData.product_name, + email: subscriptionData.user_email, + status: subscriptionData.status, + renewsAt: subscriptionData.renews_at + ? new Date(subscriptionData.renews_at) + : null, + endsAt: subscriptionData.ends_at + ? new Date(subscriptionData.ends_at) + : null, + trialEndsAt: subscriptionData.trial_ends_at + ? new Date(subscriptionData.trial_ends_at) + : null, + variantId: String(subscriptionData.variant_id), + customerPortalUrl: subscriptionData.urls.customer_portal, + updatePaymentMethodUrl: subscriptionData.urls.update_payment_method, + customerPortalUpdateSubscriptionUrl: + subscriptionData.urls.customer_portal_update_subscription, + user: { + connect: { id: userIdInDatabase }, + }, + }, + }); + await db.lemonSqueezyWebhookEvent.update({ + where: { id: createdWebhook.id }, + data: { processed: true }, + }); + break; + case "subscription_cancelled": + await db.lemonSqueezySubscription.update({ + where: { lemonSqueezyId: lemonSqueezySubscriptionId }, + data: { + status: "cancelled", + endsAt: new Date(existingSubscription?.renewsAt ?? new Date()), + }, + // Update with actual cancellation logic + }); + await db.lemonSqueezyWebhookEvent.update({ + where: { id: createdWebhook.id }, + data: { processed: true }, + }); + break; + default: + throw new Error(`Unhandled event: ${event}`); + } + + return new Response("OK", { status: 200 }); + } + + return new Response("Data invalid", { status: 400 }); +} diff --git a/src/app/api/lemon-squeezy/webhook/utils.ts b/src/app/api/lemon-squeezy/webhook/utils.ts new file mode 100644 index 0000000..0a7db1d --- /dev/null +++ b/src/app/api/lemon-squeezy/webhook/utils.ts @@ -0,0 +1,30 @@ +/** + * Check if the value is an object. + */ +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Typeguard to check if the object has a 'meta' property + * and that the 'meta' property has the correct shape. + */ +export function webhookHasMeta(obj: unknown): obj is { + meta: { + event_name: string; + custom_data: { + user_id_in_database: string; + }; + }; +} { + if ( + isObject(obj) && + isObject(obj.meta) && + typeof obj.meta.event_name === "string" && + isObject(obj.meta.custom_data) && + typeof obj.meta.custom_data.user_id_in_database === "string" + ) { + return true; + } + return false; +} diff --git a/src/env.mjs b/src/env.mjs index dff18ee..f96eb51 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -52,6 +52,9 @@ export const env = createEnv({ TRIGGER_API_KEY: z.string().optional(), TRIGGER_API_URL: z.string().optional(), LEMON_SQUEEZY_API_KEY: z.string().optional(), + LEMON_SQUEEZY_STORE_ID: z.string().optional(), + LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().optional(), + LEMON_SQUEEZY_WEBHOOK_URL: z.string().optional(), }, /** @@ -90,6 +93,9 @@ export const env = createEnv({ TRIGGER_API_KEY: process.env.TRIGGER_API_KEY, TRIGGER_API_URL: process.env.TRIGGER_API_URL, LEMON_SQUEEZY_API_KEY: process.env.LEMON_SQUEEZY_API_KEY, + LEMON_SQUEEZY_STORE_ID: process.env.LEMON_SQUEEZY_STORE_ID, + LEMON_SQUEEZY_WEBHOOK_SECRET: process.env.LEMON_SQUEEZY_WEBHOOK_SECRET, + LEMON_SQUEEZY_WEBHOOK_URL: process.env.LEMON_SQUEEZY_WEBHOOK_URL, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/server/api/root.ts b/src/server/api/root.ts index c2f4f30..6a5dd8c 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,6 +1,6 @@ import { postRouter } from "~/server/api/routers/post"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; -import { lemonSqueezyRouter } from "./routers"; +import { lemonSqueezyRouter } from "./routers/lemon-squeezy"; /** * This is the primary router for your server. diff --git a/src/server/api/routers/index.ts b/src/server/api/routers/index.ts deleted file mode 100644 index 909d452..0000000 --- a/src/server/api/routers/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { z } from "zod"; - -import { - createTRPCRouter, - protectedProcedure, - publicProcedure, -} from "~/server/api/trpc"; -import { - createCheckout, - getProduct, - lemonSqueezySetup, - listVariants, -} from "@lemonsqueezy/lemonsqueezy.js"; -import { env } from "~/env.mjs"; - -export const lemonSqueezyRouter = createTRPCRouter({ - getProductById: publicProcedure - .input( - z.object({ - productId: z.string(), - hideDefaultVariant: z.boolean().optional(), - }), - ) - .query(async ({ input }) => { - lemonSqueezySetup({ - apiKey: env.LEMON_SQUEEZY_API_KEY, - onError(error) { - console.log(error); - }, - }); - const getProductQuery = await getProduct(input.productId); - const getProductVariantsQuery = await listVariants({ - filter: { productId: input.productId }, - }); - - const variants = getProductVariantsQuery.data?.data.filter((variant) => { - if (input.hideDefaultVariant) { - return variant.attributes.status !== "pending"; - } - return true; - }); - - const product = getProductQuery.data; - - return { - product, - variants, - }; - }), - createCheckoutForVariant: publicProcedure - .input( - z.object({ - variantId: z.string(), - storeId: z.number(), - embed: z.boolean().optional(), - }), - ) - .mutation(async ({ input, ctx }) => { - lemonSqueezySetup({ - apiKey: env.LEMON_SQUEEZY_API_KEY, - onError(error) { - console.log(error); - }, - }); - - const user = ctx.session?.user; - - const checkout = await createCheckout(input.storeId, input.variantId, { - checkoutData: { - email: user?.email ?? "undefined", - custom: { - id: user?.id, - }, - }, - productOptions: { - redirectUrl: env.NEXTAUTH_URL, - }, - checkoutOptions: { - embed: input.embed, - }, - }); - - return checkout; - }), - - getSecretMessage: protectedProcedure.query(() => { - return "you can now see this secret message!"; - }), -}); diff --git a/src/server/api/routers/lemon-squeezy.ts b/src/server/api/routers/lemon-squeezy.ts new file mode 100644 index 0000000..b8681e2 --- /dev/null +++ b/src/server/api/routers/lemon-squeezy.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; + +import { + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "~/server/api/trpc"; +import { + createCheckout, + createWebhook, + getProduct, + getVariant, + lemonSqueezySetup, + listVariants, + listWebhooks, +} from "@lemonsqueezy/lemonsqueezy.js"; +import { env } from "~/env.mjs"; + +const setupLemonSqueezy = () => { + lemonSqueezySetup({ + apiKey: env.LEMON_SQUEEZY_API_KEY, + onError(error) { + console.log(error); + }, + }); +}; + +export async function hasWebhook() { + setupLemonSqueezy(); + + if (!env.LEMON_SQUEEZY_WEBHOOK_URL) { + throw new Error( + "Missing required WEBHOOK_URL env variable. Please, set it in your .env file.", + ); + } + + // Check if a webhook exists on Lemon Squeezy. + const allWebhooks = await listWebhooks({ + filter: { storeId: process.env.LEMONSQUEEZY_STORE_ID }, + }); + + // Check if WEBHOOK_URL ends with a slash. If not, add it. + const webhookUrl = env.LEMON_SQUEEZY_WEBHOOK_URL; + + const webhook = allWebhooks.data?.data.find( + (wh) => wh.attributes.url === webhookUrl && wh.attributes.test_mode, + ); + + return webhook; +} + +export const lemonSqueezyRouter = createTRPCRouter({ + getSubscriptionByUserId: protectedProcedure.query(async ({ ctx }) => { + setupLemonSqueezy(); + const user = ctx.session?.user; + + if (!user) { + throw new Error("User not found in session"); + } + + const subscription = await ctx.db.lemonSqueezySubscription.findFirst({ + where: { userId: user.id }, + }); + + const variant = subscription?.variantId + ? await getVariant(subscription?.variantId) + : null; + + return { subscription, variant }; + }), + getProductById: publicProcedure + .input( + z.object({ + productId: z.string(), + hideDefaultVariant: z.boolean().optional(), + }), + ) + .query(async ({ input }) => { + setupLemonSqueezy(); + const getProductQuery = await getProduct(input.productId); + const getProductVariantsQuery = await listVariants({ + filter: { productId: input.productId }, + }); + + const variants = getProductVariantsQuery.data?.data.filter((variant) => { + if (input.hideDefaultVariant) { + return variant.attributes.status !== "pending"; + } + return true; + }); + + const product = getProductQuery.data; + + return { + product, + variants, + }; + }), + createCheckoutForVariant: publicProcedure + .input( + z.object({ + variantId: z.string(), + embed: z.boolean().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + setupLemonSqueezy(); + + const user = ctx.session?.user; + + if (!env.LEMON_SQUEEZY_STORE_ID) { + throw new Error( + "Missing required LEMON_SQUEEZY_STORE_ID env variable. Please, set it in your .env file.", + ); + } + + const checkout = await createCheckout( + env.LEMON_SQUEEZY_STORE_ID, + input.variantId, + { + checkoutData: { + email: user?.email ?? "undefined", + custom: { + userIdInDatabase: user?.id, + }, + }, + productOptions: { + redirectUrl: `${env.NEXTAUTH_URL}/billing`, + }, + checkoutOptions: { + embed: input.embed, + }, + }, + ); + + return checkout; + }), + + createLsWebhook: protectedProcedure.mutation(async () => { + lemonSqueezySetup({ + apiKey: env.LEMON_SQUEEZY_API_KEY, + onError(error) { + console.log(error); + }, + }); + console.log({ env }); + + if (!env.LEMON_SQUEEZY_WEBHOOK_URL) { + throw new Error( + "Missing required LEMON_SQUEEZY_WEBHOOK_URL env variable. Please, set it in your .env file.", + ); + } + // Check if WEBHOOK_URL ends with a slash. If not, add it. + const webHookUrl = env.LEMON_SQUEEZY_WEBHOOK_URL; + + // Do not set a webhook on Lemon Squeezy if it already exists. + const webHook = await hasWebhook(); + + // If the webhook does not exist, create it. + if (!webHook) { + await createWebhook(env.LEMON_SQUEEZY_STORE_ID!, { + secret: env.LEMON_SQUEEZY_WEBHOOK_SECRET!, + url: webHookUrl, + testMode: true, // will create a webhook in Test mode only! + events: [ + "subscription_created", + "subscription_expired", + "subscription_updated", + ], + }); + } + }), + getWebhook: protectedProcedure.query(async () => { + const webhook = await hasWebhook(); + + return webhook; + }), +}); diff --git a/src/types/lemonsqueezy/index.ts b/src/types/lemonsqueezy/index.ts new file mode 100644 index 0000000..dad69a7 --- /dev/null +++ b/src/types/lemonsqueezy/index.ts @@ -0,0 +1,61 @@ +export interface LemonsqueezySubscriptionAttributes { + billing_anchor: number; + cancelled: boolean; + card_brand: string; + card_last_four: string; + created_at: string; + customer_id: number; + ends_at: string | null; + id: string; // Custom data + order_id: number; + order_item_id: number; + pause: string | null; + product_id: number; + product_name: string; + renews_at: string; + status: string; + status_formatted: string; + store_id: number; + test_mode: boolean; + trial_ends_at: string | null; + updated_at: string; + urls: { + update_payment_method: string; + customer_portal: string; + customer_portal_update_subscription: string; + }; + user_email: string; + user_name: string; + variant_id: number; + variant_name: string; +} + +export interface LemonsqueezyOrderAttributes { + first_subscription_item: { + id: number; + price_id: number; + subscription_id: number; + }; +} + +export interface LemonsqueezyWebhookPayload { + data: { + attributes: + | LemonsqueezyOrderAttributes + | LemonsqueezySubscriptionAttributes; + id: string; + relationships: unknown; + type: string; + }; + meta: { + custom_data: { + user_id_in_database: string; + }; + event_name: + | "order_created" + | "subscription_cancelled" + | "subscription_created" + | "subscription_updated"; + test_mode: boolean; + }; +}