mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-01 14:52:02 +00:00
update subscriptions frontend and backend to support both monthly and yearly subscriptions, tested minimally
This commit is contained in:
parent
64235797fe
commit
2b10e74d35
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Role" ADD COLUMN "subscriptionType" TEXT NOT NULL DEFAULT 'monthly';
|
@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
provider = "postgresql"
|
||||
|
@ -1,14 +1,14 @@
|
||||
// datasource db {
|
||||
// provider = "postgresql"
|
||||
// url = env("DATABASE_URL")
|
||||
// }
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_PRISMA_URL")
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING")
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// datasource db {
|
||||
// provider = "postgresql"
|
||||
// url = env("POSTGRES_PRISMA_URL")
|
||||
// directUrl = env("POSTGRES_URL_NON_POOLING")
|
||||
// }
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
@ -83,6 +83,7 @@ model Role {
|
||||
userId String @unique
|
||||
subscribed Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
subscriptionType String @default("monthly")
|
||||
subscriptionStartDate DateTime?
|
||||
lastPaymentAt DateTime?
|
||||
subscriptionExpiredAt DateTime?
|
||||
|
@ -25,6 +25,7 @@ const SubscriptionPaymentButtons = ({
|
||||
oneTime = false,
|
||||
recurring = false,
|
||||
layout = 'row',
|
||||
subscriptionType = 'monthly',
|
||||
}) => {
|
||||
const [invoice, setInvoice] = useState(null);
|
||||
const [showRecurringOptions, setShowRecurringOptions] = useState(false);
|
||||
@ -34,7 +35,13 @@ const SubscriptionPaymentButtons = ({
|
||||
const router = useRouter();
|
||||
|
||||
const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS;
|
||||
const amount = 50000;
|
||||
|
||||
// Calculate the amount based on the subscription type
|
||||
const getAmount = () => {
|
||||
return subscriptionType === 'yearly' ? 500 : 50;
|
||||
};
|
||||
|
||||
const amount = getAmount();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize Bitcoin Connect as early as possible
|
||||
@ -74,7 +81,7 @@ const SubscriptionPaymentButtons = ({
|
||||
await ln.fetch();
|
||||
const newInvoice = await ln.requestInvoice({
|
||||
satoshi: amount,
|
||||
comment: `Subscription Purchase. User: ${session?.user?.id}`,
|
||||
comment: `${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription Purchase. User: ${session?.user?.id}`,
|
||||
});
|
||||
return newInvoice;
|
||||
} catch (error) {
|
||||
@ -86,7 +93,7 @@ const SubscriptionPaymentButtons = ({
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = async response => {
|
||||
track('Subscription Payment', { method: 'pay_as_you_go', userId: session?.user?.id });
|
||||
track('Subscription Payment', { method: 'pay_as_you_go', type: subscriptionType, userId: session?.user?.id });
|
||||
showToast('success', 'Payment Successful', 'Your payment has been processed successfully.');
|
||||
if (onSuccess) onSuccess(response);
|
||||
};
|
||||
@ -117,9 +124,9 @@ const SubscriptionPaymentButtons = ({
|
||||
const initNwcOptions = {
|
||||
name: 'plebdevs.com',
|
||||
requestMethods: ['pay_invoice'],
|
||||
maxAmount: 50000,
|
||||
maxAmount: amount,
|
||||
editable: false,
|
||||
budgetRenewal: 'monthly',
|
||||
budgetRenewal: subscriptionType === 'yearly' ? 'yearly' : 'monthly',
|
||||
expiresAt: yearFromNow,
|
||||
};
|
||||
|
||||
@ -164,10 +171,11 @@ const SubscriptionPaymentButtons = ({
|
||||
userId: session.user.id,
|
||||
isSubscribed: true,
|
||||
nwc: newNWCUrl,
|
||||
subscriptionType: subscriptionType,
|
||||
});
|
||||
|
||||
if (subscriptionResponse.status === 200) {
|
||||
track('Subscription Payment', { method: 'recurring', userId: session?.user?.id });
|
||||
track('Subscription Payment', { method: 'recurring', type: subscriptionType, userId: session?.user?.id });
|
||||
showToast('success', 'Subscription Setup', 'Recurring subscription setup successful!');
|
||||
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
|
||||
} else {
|
||||
@ -229,10 +237,11 @@ const SubscriptionPaymentButtons = ({
|
||||
userId: session.user.id,
|
||||
isSubscribed: true,
|
||||
nwc: nwcInput,
|
||||
subscriptionType: subscriptionType,
|
||||
});
|
||||
|
||||
if (subscriptionResponse.status === 200) {
|
||||
track('Subscription Payment', { method: 'recurring-manual', userId: session?.user?.id });
|
||||
track('Subscription Payment', { method: 'recurring-manual', type: subscriptionType, userId: session?.user?.id });
|
||||
showToast('success', 'NWC', 'Subscription setup successful!');
|
||||
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
|
||||
} else {
|
||||
@ -256,11 +265,11 @@ const SubscriptionPaymentButtons = ({
|
||||
<>
|
||||
{!invoice && (
|
||||
<div
|
||||
className={`w-full flex ${layout === 'row' ? 'flex-row justify-between' : 'flex-col items-center'}`}
|
||||
className={`w-full flex ${layout === 'row' ? 'flex-row justify-between' : 'flex-col items-center gap-4'}`}
|
||||
>
|
||||
{(oneTime || (!oneTime && !recurring)) && (
|
||||
<GenericButton
|
||||
label="Pay as you go"
|
||||
label={`Pay as you go (${(amount).toLocaleString()} sats)`}
|
||||
icon="pi pi-bolt"
|
||||
onClick={async () => {
|
||||
if (status === 'unauthenticated') {
|
||||
@ -272,12 +281,12 @@ const SubscriptionPaymentButtons = ({
|
||||
}
|
||||
}}
|
||||
severity="primary"
|
||||
className="w-fit mt-4 text-[#f8f8ff]"
|
||||
className={`mt-4 text-[#f8f8ff] ${layout === 'col' ? 'w-full max-w-md' : 'w-fit'}`}
|
||||
/>
|
||||
)}
|
||||
{(recurring || (!oneTime && !recurring)) && (
|
||||
<GenericButton
|
||||
label="Setup Recurring Subscription"
|
||||
label={`Setup Recurring ${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription`}
|
||||
icon={
|
||||
<Image
|
||||
src="/images/nwc-logo.svg"
|
||||
@ -288,7 +297,7 @@ const SubscriptionPaymentButtons = ({
|
||||
/>
|
||||
}
|
||||
severity="help"
|
||||
className="w-fit mt-4 text-[#f8f8ff] bg-purple-600"
|
||||
className={`mt-4 text-[#f8f8ff] bg-purple-600 ${layout === 'col' ? 'w-full max-w-md' : 'w-fit'}`}
|
||||
onClick={() => {
|
||||
if (status === 'unauthenticated') {
|
||||
console.log('unauthenticated');
|
||||
@ -309,7 +318,7 @@ const SubscriptionPaymentButtons = ({
|
||||
<span className="my-4 text-lg font-bold">or</span>
|
||||
<p className="text-lg font-bold">Manually enter NWC URL</p>
|
||||
<span className="text-sm text-gray-500">
|
||||
*make sure you set a budget of at least 50000 sats and set budget renewal to monthly
|
||||
*make sure you set a budget of at least {(amount).toLocaleString()} sats and set budget renewal to {subscriptionType}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
@ -333,7 +342,7 @@ const SubscriptionPaymentButtons = ({
|
||||
onPaid={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
paymentMethods="external"
|
||||
title={`Pay ${amount} sats`}
|
||||
title={`Pay ${(amount).toLocaleString()} sats`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -17,6 +17,7 @@ import LightningAddressForm from '@/components/profile/subscription/LightningAdd
|
||||
import NostrIcon from '../../../../public/images/nostr.png';
|
||||
import Image from 'next/image';
|
||||
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
|
||||
import { SelectButton } from 'primereact/selectbutton';
|
||||
|
||||
const SubscribeModal = ({ user }) => {
|
||||
const { data: session, update } = useSession();
|
||||
@ -33,19 +34,30 @@ const SubscribeModal = ({ user }) => {
|
||||
const [nip05Visible, setNip05Visible] = useState(false);
|
||||
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
|
||||
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
|
||||
const [subscriptionType, setSubscriptionType] = useState('monthly');
|
||||
|
||||
const subscriptionOptions = [
|
||||
{ label: 'Monthly', value: 'monthly' },
|
||||
{ label: 'Yearly', value: 'yearly' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role) {
|
||||
setSubscribed(user.role.subscribed);
|
||||
setSubscriptionType(user.role.subscriptionType || 'monthly');
|
||||
const subscribedAt = new Date(user.role.lastPaymentAt);
|
||||
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Calculate subscription end date based on type
|
||||
const daysToAdd = subscriptionType === 'yearly' ? 365 : 31;
|
||||
const subscribedUntil = new Date(subscribedAt.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
|
||||
|
||||
setSubscribedUntil(subscribedUntil);
|
||||
if (user.role.subscriptionExpiredAt) {
|
||||
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
||||
setSubscriptionExpiredAt(expiredAt);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
}, [user, subscriptionType]);
|
||||
|
||||
const handleSubscriptionSuccess = async response => {
|
||||
setIsProcessing(true);
|
||||
@ -53,6 +65,7 @@ const SubscribeModal = ({ user }) => {
|
||||
const apiResponse = await axios.put('/api/users/subscription', {
|
||||
userId: session.user.id,
|
||||
isSubscribed: true,
|
||||
subscriptionType: subscriptionType,
|
||||
});
|
||||
if (apiResponse.data) {
|
||||
await update();
|
||||
@ -161,7 +174,7 @@ const SubscribeModal = ({ user }) => {
|
||||
<Message className="w-fit" severity="success" text="Subscribed!" />
|
||||
<p className="mt-3">Thank you for your support 🎉</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Pay-as-you-go subscription will renew on {subscribedUntil.toLocaleDateString()}
|
||||
Pay-as-you-go {user?.role?.subscriptionType || 'monthly'} subscription will renew on {subscribedUntil?.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -170,7 +183,7 @@ const SubscribeModal = ({ user }) => {
|
||||
<Message className="w-fit" severity="success" text="Subscribed!" />
|
||||
<p className="mt-3">Thank you for your support 🎉</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()}
|
||||
Recurring {user?.role?.subscriptionType || 'monthly'} subscription will AUTO renew on {subscribedUntil?.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -218,36 +231,68 @@ const SubscribeModal = ({ user }) => {
|
||||
</div>
|
||||
) : (
|
||||
<Card className="shadow-lg">
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-primary">Unlock Premium Benefits</h2>
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-primary mb-2">Unlock Premium Benefits</h2>
|
||||
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 mb-4 w-[60%] mx-auto">
|
||||
<div className="flex flex-col gap-6 mb-6 w-[75%] mx-auto">
|
||||
<div className="flex items-center">
|
||||
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
|
||||
<i className="pi pi-book text-2xl text-primary mr-3 text-blue-400"></i>
|
||||
<span>Access ALL current and future PlebDevs content</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
|
||||
<i className="pi pi-calendar text-2xl text-primary mr-3 text-red-400"></i>
|
||||
<span>
|
||||
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
|
||||
<i className="pi pi-bolt text-2xl text-primary mr-3 text-yellow-500"></i>
|
||||
<span>Claim your own personal plebdevs.com Lightning Address</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Image src={NostrIcon} alt="Nostr" width={26} height={26} className="mr-2" />
|
||||
<Image src={NostrIcon} alt="Nostr" width={26} height={26} className="mr-3" />
|
||||
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
|
||||
</div>
|
||||
</div>
|
||||
<SubscriptionPaymentButtons
|
||||
onSuccess={handleSubscriptionSuccess}
|
||||
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
|
||||
onError={handleSubscriptionError}
|
||||
setIsProcessing={setIsProcessing}
|
||||
/>
|
||||
|
||||
<div className="subscription-plan-selector my-8">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<h3 className="text-xl font-bold mb-4">Select Your Plan</h3>
|
||||
<SelectButton
|
||||
value={subscriptionType}
|
||||
options={subscriptionOptions}
|
||||
onChange={(e) => setSubscriptionType(e.value)}
|
||||
className="mb-3 w-full max-w-[300px] mx-auto"
|
||||
pt={{
|
||||
button: { className: 'text-base px-8 py-2 text-white' },
|
||||
root: { className: 'flex justify-center' }
|
||||
}}
|
||||
/>
|
||||
{subscriptionType === 'yearly' && (
|
||||
<div className="savings-message text-sm text-green-500 font-semibold mt-2">
|
||||
Save ~17% with yearly subscription!
|
||||
</div>
|
||||
)}
|
||||
<div className="price-display text-2xl font-bold mt-3">
|
||||
{subscriptionType === 'yearly' ? '500,000' : '50,000'} sats
|
||||
<span className="text-sm text-gray-400 ml-2">
|
||||
({subscriptionType === 'yearly' ? 'yearly' : 'monthly'})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SubscriptionPaymentButtons
|
||||
onSuccess={handleSubscriptionSuccess}
|
||||
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
|
||||
onError={handleSubscriptionError}
|
||||
setIsProcessing={setIsProcessing}
|
||||
subscriptionType={subscriptionType}
|
||||
layout="col"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Dialog>
|
||||
|
@ -16,6 +16,7 @@ import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
|
||||
import Nip05Form from '@/components/profile/subscription/Nip05Form';
|
||||
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
|
||||
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
|
||||
import { SelectButton } from 'primereact/selectbutton';
|
||||
|
||||
const UserSubscription = () => {
|
||||
const { data: session, update } = useSession();
|
||||
@ -32,10 +33,19 @@ const UserSubscription = () => {
|
||||
const [nip05Visible, setNip05Visible] = useState(false);
|
||||
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
|
||||
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
|
||||
const [subscriptionType, setSubscriptionType] = useState('monthly');
|
||||
|
||||
const subscriptionOptions = [
|
||||
{ label: 'Monthly', value: 'monthly' },
|
||||
{ label: 'Yearly', value: 'yearly' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (session && session?.user) {
|
||||
setUser(session.user);
|
||||
if (session.user.role?.subscriptionType) {
|
||||
setSubscriptionType(session.user.role.subscriptionType);
|
||||
}
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
@ -43,7 +53,11 @@ const UserSubscription = () => {
|
||||
if (user && user.role) {
|
||||
setSubscribed(user.role.subscribed);
|
||||
const subscribedAt = new Date(user.role.lastPaymentAt);
|
||||
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Calculate subscription end date based on type
|
||||
const daysToAdd = user.role.subscriptionType === 'yearly' ? 365 : 31;
|
||||
const subscribedUntil = new Date(subscribedAt.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
|
||||
|
||||
setSubscribedUntil(subscribedUntil);
|
||||
if (user.role.subscriptionExpiredAt) {
|
||||
const expiredAt = new Date(user.role.subscriptionExpiredAt);
|
||||
@ -58,6 +72,7 @@ const UserSubscription = () => {
|
||||
const apiResponse = await axios.put('/api/users/subscription', {
|
||||
userId: session.user.id,
|
||||
isSubscribed: true,
|
||||
subscriptionType: subscriptionType,
|
||||
});
|
||||
if (apiResponse.data) {
|
||||
await update();
|
||||
@ -96,6 +111,11 @@ const UserSubscription = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the subscription amount based on type
|
||||
const getAmount = () => {
|
||||
return subscriptionType === 'yearly' ? 500 : 50;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-4 px-1">
|
||||
{windowWidth < 768 && <h1 className="text-3xl font-bold mb-6">Subscription Management</h1>}
|
||||
@ -134,37 +154,66 @@ const UserSubscription = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2">
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400">
|
||||
Subscribe now and elevate your development journey!
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 mb-1">
|
||||
<div className="flex flex-col gap-5 mb-5">
|
||||
<div className="flex items-center">
|
||||
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
|
||||
<i className="pi pi-book text-2xl text-primary mr-3 text-blue-400"></i>
|
||||
<span>Access ALL current and future PlebDevs content</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
|
||||
<i className="pi pi-calendar text-2xl text-primary mr-3 text-red-400"></i>
|
||||
<span>
|
||||
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
|
||||
<i className="pi pi-bolt text-2xl text-primary mr-3 text-yellow-500"></i>
|
||||
<span>Claim your own personal plebdevs.com Lightning Address</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className="mr-2" />
|
||||
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className="mr-3" />
|
||||
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="subscription-plan-selector my-6">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<h3 className="text-xl font-bold mb-3">Select Your Plan</h3>
|
||||
<SelectButton
|
||||
value={subscriptionType}
|
||||
options={subscriptionOptions}
|
||||
onChange={(e) => setSubscriptionType(e.value)}
|
||||
className="mb-3 w-full max-w-[300px] mx-auto"
|
||||
pt={{
|
||||
button: { className: 'text-base px-8 py-2 text-white' },
|
||||
root: { className: 'flex justify-center' }
|
||||
}}
|
||||
/>
|
||||
{subscriptionType === 'yearly' && (
|
||||
<div className="savings-message text-sm text-green-500 font-semibold mt-2">
|
||||
Save ~17% with yearly subscription!
|
||||
</div>
|
||||
)}
|
||||
<div className="price-display text-2xl font-bold mt-3">
|
||||
{subscriptionType === 'yearly' ? '500,000' : '50,000'} sats
|
||||
<span className="text-sm text-gray-400 ml-2">
|
||||
({subscriptionType === 'yearly' ? 'yearly' : 'monthly'})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubscriptionPaymentButtons
|
||||
onSuccess={handleSubscriptionSuccess}
|
||||
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
|
||||
onError={handleSubscriptionError}
|
||||
setIsProcessing={setIsProcessing}
|
||||
layout={windowWidth < 768 ? 'col' : 'row'}
|
||||
subscriptionType={subscriptionType}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -176,6 +225,9 @@ const UserSubscription = () => {
|
||||
<Card
|
||||
title="Subscription Benefits"
|
||||
className="h-[330px] border border-gray-700 rounded-lg"
|
||||
pt={{
|
||||
content: { className: 'py-0' },
|
||||
}}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
|
||||
@ -186,6 +238,14 @@ const UserSubscription = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1">
|
||||
<p className="text-gray-300 mb-1">
|
||||
<span className="font-semibold">Current Plan:</span> {user?.role?.subscriptionType || 'monthly'} subscription
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
<span className="font-semibold">Renews on:</span> {subscribedUntil?.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<GenericButton
|
||||
severity="info"
|
||||
@ -249,28 +309,35 @@ const UserSubscription = () => {
|
||||
title="Frequently Asked Questions"
|
||||
className="mt-2 border border-gray-700 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
|
||||
<p>
|
||||
Think of the subscriptions as a paetreon type model. You pay a monthly fee and in
|
||||
Think of the subscriptions as a paetreon type model. You pay a monthly or yearly fee and in
|
||||
return you get access to premium features and all of the paid content. You can
|
||||
cancel at any time.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">What's the difference between monthly and yearly?</h3>
|
||||
<p>
|
||||
The yearly subscription offers a ~17% discount compared to paying monthly for a year.
|
||||
Both plans give you the same access to all features and content.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
|
||||
<p>
|
||||
The pay as you go subscription is a one-time payment that gives you access to all
|
||||
of the premium features for one month. You will need to manually renew your
|
||||
subscription every month.
|
||||
of the premium features for one month or year, depending on your selected plan. You will need to manually renew your
|
||||
subscription when it expires.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
|
||||
<p>
|
||||
The recurring subscription option allows you to submit a Nostr Wallet Connect URI
|
||||
that will be used to automatically send the subscription fee every month. You can
|
||||
that will be used to automatically send the subscription fee on your chosen schedule. You can
|
||||
cancel at any time.
|
||||
</p>
|
||||
</div>
|
||||
|
@ -11,7 +11,8 @@ const appConfig = {
|
||||
],
|
||||
authorPubkeys: [
|
||||
'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
|
||||
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345'
|
||||
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345',
|
||||
'6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4',
|
||||
],
|
||||
customLightningAddresses: [
|
||||
{
|
||||
|
@ -6,6 +6,7 @@ export const createRole = async data => {
|
||||
user: { connect: { id: data.userId } },
|
||||
admin: data.admin,
|
||||
subscribed: data.subscribed,
|
||||
subscriptionType: data.subscriptionType || 'monthly',
|
||||
// Add other fields as needed, with default values or null if not provided
|
||||
subscriptionStartDate: null,
|
||||
lastPaymentAt: null,
|
||||
|
@ -165,7 +165,7 @@ export const deleteUser = async id => {
|
||||
});
|
||||
};
|
||||
|
||||
export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
|
||||
export const updateUserSubscription = async (userId, isSubscribed, nwc, subscriptionType = 'monthly') => {
|
||||
try {
|
||||
const now = new Date();
|
||||
return await prisma.user.update({
|
||||
@ -175,6 +175,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
|
||||
upsert: {
|
||||
create: {
|
||||
subscribed: isSubscribed,
|
||||
subscriptionType: subscriptionType,
|
||||
subscriptionStartDate: isSubscribed ? now : null,
|
||||
lastPaymentAt: isSubscribed ? now : null,
|
||||
nwc: nwc ? nwc : null,
|
||||
@ -182,6 +183,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
|
||||
},
|
||||
update: {
|
||||
subscribed: isSubscribed,
|
||||
subscriptionType: subscriptionType,
|
||||
subscriptionStartDate: isSubscribed ? { set: now } : { set: null },
|
||||
lastPaymentAt: isSubscribed ? now : { set: null },
|
||||
nwc: nwc ? nwc : null,
|
||||
@ -202,20 +204,34 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
|
||||
export const findExpiredSubscriptions = async () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const oneMonthAndOneHourAgo = new Date(
|
||||
now.getTime() - 1 * 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000
|
||||
|
||||
// Define expiration periods
|
||||
const monthlyExpiration = new Date(
|
||||
now.getTime() - 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000
|
||||
);
|
||||
const yearlyExpiration = new Date(
|
||||
now.getTime() - 365 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
// Find expired subscriptions of both types
|
||||
const result = await prisma.role.findMany({
|
||||
where: {
|
||||
subscribed: true,
|
||||
lastPaymentAt: {
|
||||
lt: oneMonthAndOneHourAgo,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
subscriptionType: 'monthly',
|
||||
lastPaymentAt: { lt: monthlyExpiration }
|
||||
},
|
||||
{
|
||||
subscriptionType: 'yearly',
|
||||
lastPaymentAt: { lt: yearlyExpiration }
|
||||
}
|
||||
]
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
nwc: true,
|
||||
subscriptionType: true,
|
||||
subscriptionExpiredAt: true,
|
||||
subscriptionStartDate: true,
|
||||
admin: true,
|
||||
@ -231,6 +247,24 @@ export const findExpiredSubscriptions = async () => {
|
||||
export const expireUserSubscriptions = async userIds => {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// First, get the subscription types for each userId
|
||||
const subscriptions = await prisma.role.findMany({
|
||||
where: {
|
||||
userId: { in: userIds },
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
subscriptionType: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a map of userId to subscription type
|
||||
const subscriptionTypes = {};
|
||||
subscriptions.forEach(sub => {
|
||||
subscriptionTypes[sub.userId] = sub.subscriptionType || 'monthly';
|
||||
});
|
||||
|
||||
const updatePromises = userIds.map(userId =>
|
||||
prisma.role.update({
|
||||
where: { userId },
|
||||
@ -240,6 +274,8 @@ export const expireUserSubscriptions = async userIds => {
|
||||
lastPaymentAt: null,
|
||||
nwc: null,
|
||||
subscriptionExpiredAt: now,
|
||||
// Keep the subscription type for historical data and easy renewal
|
||||
// subscriptionType: Don't change the existing value
|
||||
},
|
||||
})
|
||||
);
|
||||
|
@ -7,18 +7,39 @@ import { webln } from '@getalby/sdk';
|
||||
import { LightningAddress } from '@getalby/lightning-tools';
|
||||
|
||||
const lnAddress = process.env.LIGHTNING_ADDRESS;
|
||||
const amount = 50000; // Set the subscription amount in satoshis
|
||||
|
||||
// Calculate subscription amount based on type
|
||||
const getAmount = (subscriptionType) => {
|
||||
// 500K for yearly (saves ~17% compared to monthly), 50K for monthly
|
||||
return subscriptionType === 'yearly' ? 500 : 50;
|
||||
};
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
// Get all expired subscriptions (both monthly and yearly)
|
||||
// The findExpiredSubscriptions function handles different expiration periods:
|
||||
// - Monthly: 30 days + 1 hour
|
||||
// - Yearly: 365 days + 1 hour
|
||||
const expiredSubscriptions = await findExpiredSubscriptions();
|
||||
console.log('expiredSubscriptions', expiredSubscriptions);
|
||||
console.log(`Found ${expiredSubscriptions.length} expired subscriptions to process`);
|
||||
|
||||
// Track stats for reporting
|
||||
const stats = {
|
||||
monthly: { processed: 0, renewed: 0, expired: 0 },
|
||||
yearly: { processed: 0, renewed: 0, expired: 0 },
|
||||
};
|
||||
|
||||
const stillExpired = [];
|
||||
|
||||
for (const { userId, nwc } of expiredSubscriptions) {
|
||||
for (const { userId, nwc, subscriptionType = 'monthly' } of expiredSubscriptions) {
|
||||
// Track processed subscriptions by type
|
||||
stats[subscriptionType].processed++;
|
||||
|
||||
if (nwc) {
|
||||
try {
|
||||
console.log(`Processing ${subscriptionType} subscription renewal for user ${userId}`);
|
||||
const amount = getAmount(subscriptionType);
|
||||
const nwcProvider = new webln.NostrWebLNProvider({
|
||||
nostrWalletConnectUrl: nwc,
|
||||
});
|
||||
@ -26,30 +47,49 @@ export default async function handler(req, res) {
|
||||
|
||||
const ln = new LightningAddress(lnAddress);
|
||||
await ln.fetch();
|
||||
const newInvoice = await ln.requestInvoice({ satoshi: amount });
|
||||
const newInvoice = await ln.requestInvoice({
|
||||
satoshi: amount,
|
||||
comment: `${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription Renewal for User: ${userId}`,
|
||||
});
|
||||
|
||||
console.log(`Generated invoice for ${amount} sats for ${subscriptionType} subscription`);
|
||||
const response = await nwcProvider.sendPayment(newInvoice?.paymentRequest);
|
||||
|
||||
if (response && response?.preimage) {
|
||||
console.log(`SUBSCRIPTION AUTO-RENEWED`, response);
|
||||
await updateUserSubscription(userId, true, nwc);
|
||||
console.log(`SUBSCRIPTION AUTO-RENEWED (${subscriptionType}) for User: ${userId}`);
|
||||
// Re-subscribe the user with the same subscription type
|
||||
await updateUserSubscription(userId, true, nwc, subscriptionType);
|
||||
// Track successful renewals
|
||||
stats[subscriptionType].renewed++;
|
||||
continue; // Skip adding to stillExpired list
|
||||
} else {
|
||||
console.log(`Payment failed for user ${userId}: (stillExpired)`, response);
|
||||
console.log(`Payment failed for ${subscriptionType} subscription for user ${userId}: (stillExpired)`, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Payment failed for user ${userId}:`, error);
|
||||
console.error(`Payment failed for ${subscriptionType} subscription for user ${userId}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(`No NWC found for user ${userId}, marking as expired`);
|
||||
}
|
||||
|
||||
// Track failed renewals that will be expired
|
||||
stats[subscriptionType].expired++;
|
||||
stillExpired.push(userId);
|
||||
}
|
||||
|
||||
// Expire all subscriptions that couldn't be renewed
|
||||
const expiredCount = await expireUserSubscriptions(stillExpired);
|
||||
|
||||
console.log(`Processed ${expiredSubscriptions.length} total subscriptions (${stats.monthly.processed} monthly, ${stats.yearly.processed} yearly)`);
|
||||
console.log(`Renewed ${stats.monthly.renewed + stats.yearly.renewed} total subscriptions (${stats.monthly.renewed} monthly, ${stats.yearly.renewed} yearly)`);
|
||||
console.log(`Expired ${expiredCount} total subscriptions (${stats.monthly.expired} monthly, ${stats.yearly.expired} yearly)`);
|
||||
|
||||
res.status(200).json({
|
||||
message: `Cron job completed successfully.
|
||||
Processed ${expiredSubscriptions.length} subscriptions.
|
||||
Expired ${expiredCount} subscriptions.`,
|
||||
Processed ${expiredSubscriptions.length} subscriptions (${stats.monthly.processed} monthly, ${stats.yearly.processed} yearly).
|
||||
Renewed ${stats.monthly.renewed + stats.yearly.renewed} subscriptions (${stats.monthly.renewed} monthly, ${stats.yearly.renewed} yearly).
|
||||
Expired ${expiredCount} subscriptions (${stats.monthly.expired} monthly, ${stats.yearly.expired} yearly).`,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cron job error:', error);
|
||||
|
@ -12,8 +12,8 @@ export default async function handler(req, res) {
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
try {
|
||||
const { userId, isSubscribed, nwc } = req.body;
|
||||
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc);
|
||||
const { userId, isSubscribed, nwc, subscriptionType = 'monthly' } = req.body;
|
||||
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc, subscriptionType);
|
||||
|
||||
res.status(200).json(updatedUser);
|
||||
} catch (error) {
|
||||
|
195
yearly_subscriptions.md
Normal file
195
yearly_subscriptions.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Yearly Subscription Implementation Plan
|
||||
|
||||
## 1. Database Schema Updates
|
||||
|
||||
```prisma
|
||||
model Role {
|
||||
// Existing fields...
|
||||
subscriptionType String @default("monthly") // Options: "monthly", "yearly"
|
||||
// Other fields remain the same
|
||||
}
|
||||
```
|
||||
|
||||
## 2. UI Component Updates
|
||||
|
||||
### SubscribeModal.js
|
||||
- Add toggle between monthly/yearly subscription options
|
||||
- Update pricing display (50,000 sats monthly / 500,000 sats yearly)
|
||||
- Show savings message for yearly option (~17% discount)
|
||||
|
||||
### SubscriptionPaymentButton.js
|
||||
- Add subscription type parameter
|
||||
- Modify amount calculation based on subscription type
|
||||
- Update NWC configuration for yearly budgets
|
||||
|
||||
```javascript
|
||||
// Example modification
|
||||
const getAmount = (subscriptionType) => {
|
||||
return subscriptionType === 'yearly' ? 500000 : 50000;
|
||||
};
|
||||
|
||||
// For NWC setup
|
||||
const budgetRenewal = subscriptionType === 'yearly' ? 'yearly' : 'monthly';
|
||||
```
|
||||
|
||||
## 3. API Endpoints Updates
|
||||
|
||||
### /api/users/subscription
|
||||
- Update to accept subscriptionType parameter
|
||||
- Modify database update to store subscription type
|
||||
|
||||
```javascript
|
||||
// Example modification
|
||||
export const updateUserSubscription = async (userId, isSubscribed, nwc, subscriptionType = 'monthly') => {
|
||||
try {
|
||||
const now = new Date();
|
||||
return await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
role: {
|
||||
upsert: {
|
||||
create: {
|
||||
subscribed: isSubscribed,
|
||||
subscriptionType: subscriptionType,
|
||||
// Other fields remain the same
|
||||
},
|
||||
update: {
|
||||
subscribed: isSubscribed,
|
||||
subscriptionType: subscriptionType,
|
||||
// Other fields remain the same
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 4. Cron Job Modifications
|
||||
|
||||
### cron.js
|
||||
- Update expiration calculation to check subscription type
|
||||
- For monthly: expire after 30 days + 1 hour
|
||||
- For yearly: expire after 365 days + 1 hour
|
||||
|
||||
```javascript
|
||||
export const findExpiredSubscriptions = async () => {
|
||||
const now = new Date();
|
||||
|
||||
// Define expiration periods
|
||||
const monthlyExpiration = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000);
|
||||
const yearlyExpiration = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000);
|
||||
|
||||
// Find expired subscriptions of both types
|
||||
const expiredSubscriptions = await prisma.role.findMany({
|
||||
where: {
|
||||
subscribed: true,
|
||||
OR: [
|
||||
{
|
||||
subscriptionType: 'monthly',
|
||||
lastPaymentAt: { lt: monthlyExpiration }
|
||||
},
|
||||
{
|
||||
subscriptionType: 'yearly',
|
||||
lastPaymentAt: { lt: yearlyExpiration }
|
||||
}
|
||||
]
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
nwc: true,
|
||||
subscriptionType: true,
|
||||
subscriptionExpiredAt: true,
|
||||
subscriptionStartDate: true,
|
||||
admin: true,
|
||||
}
|
||||
});
|
||||
|
||||
return expiredSubscriptions;
|
||||
};
|
||||
```
|
||||
|
||||
## 5. Testing Plan
|
||||
|
||||
### Database Testing
|
||||
1. Verify Prisma schema correctly includes the subscriptionType field with default value "monthly"
|
||||
2. Confirm migrations apply correctly to existing database
|
||||
|
||||
### UI Testing
|
||||
1. **Monthly Subscription UI**
|
||||
- Verify the subscription selector defaults to monthly
|
||||
- Check pricing shows 50,000 sats for monthly
|
||||
- Ensure subscription buttons show "Monthly" where appropriate
|
||||
|
||||
2. **Yearly Subscription UI**
|
||||
- Verify selecting yearly plan updates all UI elements
|
||||
- Check pricing shows 500,000 sats for yearly
|
||||
- Confirm ~17% savings message appears
|
||||
- Ensure subscription buttons show "Yearly" where appropriate
|
||||
|
||||
### Payment Flow Testing
|
||||
1. **Monthly One-time Payment**
|
||||
- Test subscription purchase with "Pay as you go" for monthly plan
|
||||
- Verify subscription is created with type "monthly"
|
||||
- Confirm user profile shows correct subscription expiration date (30 days)
|
||||
|
||||
2. **Monthly Recurring Payment**
|
||||
- Test subscription setup with "Setup Recurring Monthly Subscription"
|
||||
- Verify NWC configuration with monthly budget renewal
|
||||
- Confirm subscription is created with type "monthly"
|
||||
|
||||
3. **Yearly One-time Payment**
|
||||
- Test subscription purchase with "Pay as you go" for yearly plan
|
||||
- Verify subscription is created with type "yearly"
|
||||
- Confirm user profile shows correct subscription expiration date (365 days)
|
||||
|
||||
4. **Yearly Recurring Payment**
|
||||
- Test subscription setup with "Setup Recurring Yearly Subscription"
|
||||
- Verify NWC configuration with yearly budget renewal
|
||||
- Confirm subscription is created with type "yearly"
|
||||
|
||||
### Cron Job Testing
|
||||
1. **Recently Active Monthly Subscription**
|
||||
- Set up test account with monthly subscription
|
||||
- Verify subscription not marked as expired by cron job
|
||||
|
||||
2. **Recently Active Yearly Subscription**
|
||||
- Set up test account with yearly subscription
|
||||
- Verify subscription not marked as expired by cron job
|
||||
|
||||
3. **Expired Monthly Subscription**
|
||||
- Create test account with monthly subscription
|
||||
- Manually adjust lastPaymentAt date to be >30 days ago
|
||||
- Run cron job and verify subscription is expired
|
||||
|
||||
4. **Expired Yearly Subscription**
|
||||
- Create test account with yearly subscription
|
||||
- Manually adjust lastPaymentAt date to be >365 days ago
|
||||
- Run cron job and verify subscription is expired
|
||||
|
||||
5. **Auto-renewal Testing**
|
||||
- Set up NWC for test accounts (both monthly and yearly)
|
||||
- Manually adjust lastPaymentAt date to trigger expiration
|
||||
- Run cron job and verify proper renewal amount is charged
|
||||
- Confirm subscription type is maintained after renewal
|
||||
|
||||
## 6. Implementation Steps
|
||||
|
||||
1. ✅ Create database migration for schema changes
|
||||
2. ✅ Modify frontend subscription components
|
||||
3. ✅ Update backend models and API endpoints
|
||||
4. ✅ Update cron job logic
|
||||
5. Test all flows thoroughly
|
||||
6. Deploy changes
|
||||
|
||||
## 7. Marketing Considerations
|
||||
|
||||
- Highlight savings with yearly subscription (~17% discount)
|
||||
- Update documentation and marketing materials
|
||||
- Consider grandfathering existing subscribers or offering upgrade path
|
Loading…
x
Reference in New Issue
Block a user