-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #115 from codelitdev/issue-113
Billing screen and webhook
- Loading branch information
Showing
17 changed files
with
896 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
db.users.updateMany({}, { $set: { subscriptionStatus: "not-subscribed" } }); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
"use server"; | ||
|
||
import { Session } from "next-auth"; | ||
import connectToDatabase from "@/lib/connect-db"; | ||
import UserModel from "@/models/user"; | ||
import { LEMONSQUEEZY_API_KEY } from "@/lib/constants"; | ||
import { error } from "@/utils/logger"; | ||
import { auth } from "@/auth"; | ||
import { User } from "@medialit/models"; | ||
|
||
export async function cancelSubscription( | ||
prevState: Record<string, unknown>, | ||
formData: FormData | ||
): Promise<{ | ||
success: boolean; | ||
error?: string; | ||
}> { | ||
try { | ||
if (!LEMONSQUEEZY_API_KEY) { | ||
throw new Error("Lemon API key not found"); | ||
} | ||
|
||
const session: Session | null = await auth(); | ||
if (!session || !session.user) { | ||
throw new Error("Unauthorized"); | ||
} | ||
|
||
await connectToDatabase(); | ||
|
||
const user: User | null = await UserModel.findOne({ | ||
email: session.user.email, | ||
}); | ||
|
||
if (!user) { | ||
throw new Error("Unauthorized"); | ||
} | ||
|
||
const response = await fetch( | ||
`https://api.lemonsqueezy.com/v1/subscriptions/${user.subscriptionId}`, | ||
{ | ||
method: "DELETE", | ||
headers: { | ||
"Content-Type": "application/vnd.api+json", | ||
Accept: "application/vnd.api+json", | ||
Authorization: `Bearer ${LEMONSQUEEZY_API_KEY}`, | ||
}, | ||
} | ||
); | ||
if (response.ok) { | ||
const resp = await response.json(); | ||
return { success: true }; | ||
} | ||
|
||
throw new Error("Some error occurred"); | ||
} catch (error: any) { | ||
return { success: false, error: error.message }; | ||
} | ||
} | ||
|
||
export async function resumeSubscription( | ||
prevState: Record<string, unknown>, | ||
formData: FormData | ||
): Promise<{ | ||
success: boolean; | ||
error?: string; | ||
}> { | ||
try { | ||
if (!LEMONSQUEEZY_API_KEY) { | ||
throw new Error("Lemon API key not found"); | ||
} | ||
|
||
const session: Session | null = await auth(); | ||
if (!session || !session.user) { | ||
throw new Error("Unauthorized"); | ||
} | ||
|
||
await connectToDatabase(); | ||
|
||
const user: User | null = await UserModel.findOne({ | ||
email: session.user.email, | ||
}); | ||
|
||
if (!user) { | ||
throw new Error("Unauthorized"); | ||
} | ||
|
||
const response = await fetch( | ||
`https://api.lemonsqueezy.com/v1/subscriptions/${user.subscriptionId}`, | ||
{ | ||
method: "PATCH", | ||
headers: { | ||
"Content-Type": "application/vnd.api+json", | ||
Accept: "application/vnd.api+json", | ||
Authorization: `Bearer ${LEMONSQUEEZY_API_KEY}`, | ||
}, | ||
body: JSON.stringify({ | ||
data: { | ||
type: "subscriptions", | ||
id: user.subscriptionId, | ||
attributes: { | ||
cancelled: false, | ||
}, | ||
}, | ||
}), | ||
} | ||
); | ||
const resp = await response.json(); | ||
|
||
if (response.ok) { | ||
return { success: true }; | ||
} | ||
|
||
error(`Error in resuming subscription`, { | ||
userId: user.subscriptionId, | ||
apiResponse: resp, | ||
statusCode: response.status, | ||
}); | ||
return { | ||
success: false, | ||
error: "Some error occurred while resuming subscription. Try again in a while.", | ||
}; | ||
} catch (err: any) { | ||
error(`Error in resuming subscription`, err.stack); | ||
return { success: false, error: err.message }; | ||
} | ||
} |
136 changes: 136 additions & 0 deletions
136
apps/web/app/account/billing/cancel-subscription-button.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
"use client"; | ||
|
||
import { Button } from "@/components/ui/button"; | ||
import { useFormState, useFormStatus } from "react-dom"; | ||
import { cancelSubscription } from "./action"; | ||
import { useEffect, useState } from "react"; | ||
import { useToast } from "@/components/ui/use-toast"; | ||
import { useRouter } from "next/navigation"; | ||
import { | ||
Dialog, | ||
DialogContent, | ||
DialogDescription, | ||
DialogFooter, | ||
DialogHeader, | ||
DialogTitle, | ||
DialogTrigger, | ||
DialogClose, | ||
} from "@/components/ui/dialog"; | ||
|
||
export default function CancelSubscriptionButton({ | ||
subscriptionStatus, | ||
currentPlan, | ||
}: { | ||
subscriptionStatus: string; | ||
currentPlan: string; | ||
}) { | ||
const [formState, formAction] = useFormState(cancelSubscription, { | ||
success: false, | ||
}); | ||
const { toast } = useToast(); | ||
const router = useRouter(); | ||
|
||
const [open, setOpen] = useState(false); | ||
|
||
useEffect(() => { | ||
if (formState.success) { | ||
setOpen(false); | ||
toast({ | ||
title: "We are sorry to see you go", | ||
description: "Your subscription has been cancelled", | ||
}); | ||
router.refresh(); | ||
} | ||
if (formState.error) { | ||
toast({ | ||
title: "Uh oh!", | ||
description: formState.error, | ||
}); | ||
} | ||
}, [formState]); | ||
|
||
return ( | ||
<> | ||
<Dialog open={open} onOpenChange={setOpen}> | ||
<DialogTrigger asChild> | ||
{currentPlan === "Basic" && | ||
subscriptionStatus === "subscribed" ? ( | ||
<Button className="bg-primary hover:bg-[#333333]"> | ||
Downgrade to free | ||
</Button> | ||
) : ( | ||
<Button className="bg-red-600 hover:bg-red-700"> | ||
Cancel subscription | ||
</Button> | ||
)} | ||
</DialogTrigger> | ||
<DialogContent className="sm:max-w-[425px]"> | ||
{currentPlan === "Basic" && | ||
subscriptionStatus === "subscribed" ? ( | ||
<> | ||
<DialogHeader> | ||
<DialogTitle>Downgrade to free</DialogTitle> | ||
</DialogHeader> | ||
Are you sure, you want to cancel your current | ||
subscription? | ||
</> | ||
) : ( | ||
<> | ||
<DialogHeader> | ||
<DialogTitle>Cancel subscription</DialogTitle> | ||
</DialogHeader> | ||
Are you sure, you want to cancel subscription? | ||
</> | ||
)} | ||
|
||
<form action={formAction}> | ||
<DialogFooter> | ||
<DialogClose asChild> | ||
<Button>Nevermind</Button> | ||
</DialogClose> | ||
<DialogClose asChild> | ||
<Submit | ||
currentPlan={currentPlan} | ||
subscriptionStatus={subscriptionStatus} | ||
> | ||
Yes! Cancel | ||
</Submit> | ||
</DialogClose> | ||
</DialogFooter> | ||
</form> | ||
</DialogContent> | ||
</Dialog> | ||
</> | ||
); | ||
} | ||
|
||
function Submit({ | ||
children, | ||
currentPlan, | ||
subscriptionStatus, | ||
}: { | ||
children: React.ReactNode; | ||
currentPlan: string; | ||
subscriptionStatus: string; | ||
}) { | ||
const status = useFormStatus(); | ||
|
||
let buttonText = children; | ||
let className; | ||
|
||
if (currentPlan === "Basic" && subscriptionStatus === "subscribed") { | ||
buttonText = "Yes! Cancel"; | ||
className = "bg-red-500 hover:bg-red-700"; | ||
} | ||
|
||
return ( | ||
<Button | ||
className={`bg-red-500 hover:bg-red-700 text-white ${className}`} | ||
type="submit" | ||
variant="secondary" | ||
disabled={status.pending} | ||
> | ||
{buttonText} | ||
</Button> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import Link from "next/link"; | ||
import { ReactNode } from "react"; | ||
import { redirect } from "next/navigation"; | ||
import Script from "next/script"; | ||
import { auth } from "@/auth"; | ||
|
||
export default async function SchoolDetailsLayout({ | ||
params, | ||
children, | ||
}: { | ||
params: { name: string }; | ||
children: ReactNode; | ||
}) { | ||
const session = await auth(); | ||
|
||
if (!session) { | ||
return redirect("/login?from=/account/billing"); | ||
} | ||
|
||
return ( | ||
<div> | ||
<div className="text-primary font-semibold py-2"> | ||
<Link href="/" className="hover:border-b-2 border-primary"> | ||
All apps{" "} | ||
</Link> | ||
/ <span className="text-muted-foreground">Billing</span> | ||
</div> | ||
<Script | ||
src="https://app.lemonsqueezy.com/js/lemon.js" | ||
strategy="beforeInteractive" | ||
id="lemonsqueezy" | ||
/> | ||
{children} | ||
</div> | ||
); | ||
} |
Oops, something went wrong.